diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/ListExtension.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/ListExtension.kt index 112195b1..579cf816 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/ListExtension.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/ListExtension.kt @@ -1,5 +1,7 @@ package com.sunnychung.application.multiplatform.hellohttp.extension +import kotlin.math.roundToInt + /** * Can only be used on a **sorted** list. * @@ -10,3 +12,16 @@ fun List.binarySearchForInsertionPoint(comparison: (T) -> Int): Int { if (r >= 0) throw IllegalArgumentException("Parameter `comparison` should never return 0") return -(r + 1) } + +fun List.atPercent(percent: Int): Double { + if (isEmpty()) return 0.0 + return if (percent == 50) { + if (size % 2 == 0) { + this[lastIndex / 2].toDouble() / 2.0 + this[lastIndex / 2 + 1].toDouble() / 2.0 + } else { + this[lastIndex / 2].toDouble() + } + } else { + this[(lastIndex * percent.toDouble() / 100.0).roundToInt()].toDouble() + } +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/MapExtension.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/MapExtension.kt new file mode 100644 index 00000000..25d91b71 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/MapExtension.kt @@ -0,0 +1,10 @@ +package com.sunnychung.application.multiplatform.hellohttp.extension + +import java.util.SortedMap + +fun SortedMap.lastOrNull(): V? = + if (isEmpty()) { + null + } else { + get(lastKey()) + } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/manager/NetworkClientManager.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/manager/NetworkClientManager.kt index 4917fc06..fce66c81 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/manager/NetworkClientManager.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/manager/NetworkClientManager.kt @@ -14,12 +14,18 @@ import com.sunnychung.application.multiplatform.hellohttp.helper.VariableResolve import com.sunnychung.application.multiplatform.hellohttp.model.Environment import com.sunnychung.application.multiplatform.hellohttp.model.FieldValueType import com.sunnychung.application.multiplatform.hellohttp.model.HttpConfig +import com.sunnychung.application.multiplatform.hellohttp.model.HttpRequest +import com.sunnychung.application.multiplatform.hellohttp.model.LoadTestInput +import com.sunnychung.application.multiplatform.hellohttp.model.LoadTestResponse +import com.sunnychung.application.multiplatform.hellohttp.model.LoadTestState +import com.sunnychung.application.multiplatform.hellohttp.model.LongDuplicateContainer import com.sunnychung.application.multiplatform.hellohttp.model.PayloadMessage import com.sunnychung.application.multiplatform.hellohttp.model.ProtocolApplication import com.sunnychung.application.multiplatform.hellohttp.model.SslConfig import com.sunnychung.application.multiplatform.hellohttp.model.UserKeyValuePair import com.sunnychung.application.multiplatform.hellohttp.model.UserRequestTemplate import com.sunnychung.application.multiplatform.hellohttp.model.UserResponse +import com.sunnychung.application.multiplatform.hellohttp.model.UserResponseByResponseTime import com.sunnychung.application.multiplatform.hellohttp.network.CallData import com.sunnychung.application.multiplatform.hellohttp.network.ConnectionStatus import com.sunnychung.application.multiplatform.hellohttp.network.LiteCallData @@ -27,11 +33,15 @@ import com.sunnychung.application.multiplatform.hellohttp.network.hostFromUrl import com.sunnychung.application.multiplatform.hellohttp.util.log import com.sunnychung.application.multiplatform.hellohttp.util.upsert import com.sunnychung.application.multiplatform.hellohttp.util.uuidString +import com.sunnychung.lib.multiplatform.kdatetime.KInstant import io.grpc.Status import io.grpc.StatusRuntimeException import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -75,7 +85,15 @@ class NetworkClientManager : CallDataStore { override fun provideCallDataStore(): ConcurrentHashMap = callDataMap override fun provideLiteCallDataStore(): ConcurrentHashMap = liteCallDataMap - fun fireRequest(request: UserRequestTemplate, requestExampleId: String, environment: Environment?, projectId: String, subprojectId: String) { + fun fireRequest( + request: UserRequestTemplate, + requestExampleId: String, + environment: Environment?, + projectId: String, + subprojectId: String, + fireType: UserResponse.Type, + parentLoadTestState: LoadTestState? = null, + ) : CallData { val callData = try { val networkRequest = request.toHttpRequest( exampleId = requestExampleId, @@ -165,12 +183,7 @@ class NetworkClientManager : CallDataStore { null } - val networkManager = when (networkRequest.application) { - ProtocolApplication.Graphql -> graphqlSubscriptionTransportClient - ProtocolApplication.WebSocket -> webSocketTransportClient - ProtocolApplication.Grpc -> grpcTransportClient - else -> httpTransportClient - } + val networkManager = networkRequest.getNetworkManager() networkManager.sendRequest( request = networkRequest, @@ -180,6 +193,8 @@ class NetworkClientManager : CallDataStore { postFlightAction = postFlightAction, httpConfig = environment?.httpConfig ?: HttpConfig(), sslConfig = environment?.sslConfig ?: SslConfig(), + fireType = fireType, + parentLoadTestState = parentLoadTestState, ) } catch (error: Throwable) { val d = CallData( @@ -193,6 +208,7 @@ class NetworkClientManager : CallDataStore { isError = true, errorMessage = error.message ), + fireType = fireType, events = emptySharedFlow(), eventsStateFlow = MutableStateFlow(null), @@ -202,22 +218,167 @@ class NetworkClientManager : CallDataStore { cancel = {}, ) log.d(error) { "Got error while firing request" } + + if (parentLoadTestState != null) { + d.complete() + onCompleteResponse(d) +// callDataMap[parentLoadTestState.callId]?.cancel?.invoke(error) + } + // `networkManager.sendRequest` would update callDataMap, but on error nobody updates // so manually update here callDataMap[d.id] = d d } - val oldCallId = requestExampleToCallMapping.put(requestExampleId, callData.id) - if (oldCallId != null) { - CoroutineScope(Dispatchers.IO).launch { - callDataMap[oldCallId]?.cancel?.invoke(null) - callDataMap.remove(oldCallId) + if (fireType != UserResponse.Type.LoadTestChild) { + val oldCallId = requestExampleToCallMapping.put(requestExampleId, callData.id) + if (oldCallId != null) { + CoroutineScope(Dispatchers.IO).launch { + callDataMap[oldCallId]?.cancel?.invoke(null) + callDataMap.remove(oldCallId) + } } + callIdFlow.value = callData.id + + persistResponseManager.registerCall(callData) } - callIdFlow.value = callData.id - persistResponseManager.registerCall(callData) if (!callData.response.isError) { callData.isPrepared = true + if (parentLoadTestState != null) { + parentLoadTestState.numRequestsSent.incrementAndGet() + } + } + return callData + } + + private fun HttpRequest.getNetworkManager() = when (this.application) { + ProtocolApplication.Graphql -> graphqlSubscriptionTransportClient + ProtocolApplication.WebSocket -> webSocketTransportClient + ProtocolApplication.Grpc -> grpcTransportClient + else -> httpTransportClient + } + + fun fireLoadTestRequests( + input: LoadTestInput, + request: UserRequestTemplate, + requestExampleId: String, + environment: Environment?, + projectId: String, + subprojectId: String, + ) { + with (CoroutineScope(Dispatchers.IO)) { + launch { + val coroutineContext = currentCoroutineContext() + val input = input.copy( + requestId = request.id, + requestExampleId = requestExampleId, + application = ProtocolApplication.Http, +// requestData = // TODO refactor into specific transport manager + ) + // TODO refactor into specific transport manager + val loadTestState = LoadTestState(input = input, startAt = KInstant.now(), callId = uuidString()) { resp -> + if (resp.isError) { + LoadTestResponse.Category.ClientError + } else { + if (resp.statusCode?.let { it >= 200 && it < 300 } == true) { + LoadTestResponse.Category.Success + } else { + LoadTestResponse.Category.ServerError + } + } + } + + var isCompleted = false + val networkRequest = request.toHttpRequest( + exampleId = requestExampleId, + environment = environment + ) + val networkManager = networkRequest.getNetworkManager() + val callData = networkManager.createCallData( + callId = loadTestState.callId, + requestBodySize = null, + requestExampleId = requestExampleId, + requestId = request.id, + subprojectId = subprojectId, + sslConfig = environment?.sslConfig ?: SslConfig(), + fireType = UserResponse.Type.LoadTest, + loadTestState = loadTestState, + ) + callData.cancel = { e -> + isCompleted = true + callData.status = ConnectionStatus.DISCONNECTED + callData.response.loadTestResult = runBlocking { loadTestState.toResult(1000L) } + networkManager.emitEvent(callData.id, "Completed") + coroutineContext.cancel(e?.let { CancellationException("Cancelled due to error: ${e.message}", e) }) + } + callData.status = ConnectionStatus.CONNECTING + callData.response.startAt = KInstant.now() + persistResponseManager.registerCall(callData) + + val endTime = callData.response.startAt!! + input.intendedDuration + + val oldCallId = requestExampleToCallMapping.put(requestExampleId, callData.id) + if (oldCallId != null) { + CoroutineScope(Dispatchers.IO).launch { + callDataMap[oldCallId]?.cancel?.invoke(null) + callDataMap.remove(oldCallId) + } + } + callIdFlow.value = callData.id + + val jobs = (1..input.numConcurrent).map { i -> + launch { + do { + log.v { "LoadTest fireRequest C#$i" } + val call = fireRequest( + request = request, + requestExampleId = requestExampleId, + environment = environment, + projectId = projectId, + subprojectId = subprojectId, + fireType = UserResponse.Type.LoadTestChild, + parentLoadTestState = loadTestState, + ) + call.awaitComplete() + log.v { "LoadTest complete C#$i" } + onCompleteResponse(call) + log.v { "LoadTest onCompleteResponse C#$i" } + } while ( + callData.status != ConnectionStatus.DISCONNECTED // not cancelled + && !call.response.isError // not client-side error + && KInstant.now() < endTime + ) + } + } + + val reportJob = launch { + while (!isCompleted) { + callData.response.loadTestResult = loadTestState.toResult(1000L) + networkManager.emitEvent(callData.id, "update report") + delay(1000) + } + callData.response.loadTestResult = loadTestState.toResult(1000L) + networkManager.emitEvent(callData.id, "update report") + } + + jobs.forEach { it.join() } + callData.response.endAt = KInstant.now() + isCompleted = true + reportJob.join() + callData.response.loadTestResult = loadTestState.toResult(1000L) + callData.status = ConnectionStatus.DISCONNECTED + networkManager.emitEvent(callData.id, "Completed") + log.d { "Complete load test. Result: ${callData.response.loadTestResult}" } + } + } + } + + private fun onCompleteResponse(call: CallData) { + val response = call.response + call.loadTestState?.let { loadTestState -> + val endAt = response.endAt ?: KInstant.now() + loadTestState.latenciesMs += LongDuplicateContainer((endAt - response.startAt!!).toMilliseconds()) + loadTestState.responsesOverResponseTime += UserResponseByResponseTime(response) } } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/model/LoadTestResult.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/model/LoadTestResult.kt new file mode 100644 index 00000000..d9f6d732 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/model/LoadTestResult.kt @@ -0,0 +1,164 @@ +package com.sunnychung.application.multiplatform.hellohttp.model + +import com.fasterxml.jackson.module.kotlin.jsonMapper +import com.sunnychung.application.multiplatform.hellohttp.extension.atPercent +import com.sunnychung.application.multiplatform.hellohttp.util.log +import com.sunnychung.lib.multiplatform.kdatetime.KDuration +import com.sunnychung.lib.multiplatform.kdatetime.KInstant +import com.sunnychung.lib.multiplatform.kdatetime.serializer.KInstantAsLong +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.Collections +import java.util.SortedMap +import java.util.SortedSet +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong +import kotlin.math.roundToInt + +/** + * The data structure should support: + * - Number of unresponsive requests + * - Number of responses per status code + * - Histogram of latencies (max 1001 samples), including min, max + * - Average latency + * - Success/Error rate over response time + * - Average/Min/Max/Median/90%/95%/99% Latencies over response time + * - Number of pending requests over time + */ +data class LoadTestResult( + val numRequestsSent: Int, + val numResponses: Int, + val numResponsesByStatusCode: MutableMap = ConcurrentHashMap(), + val latenciesMsHistogram: List, + val averageLatencyMs: Double, + val numCatagorizedResponsesOverTime: SortedMap>, + val latenciesMsOverTime: SortedMap, + val startAt: KInstantAsLong, + val endAt: KInstantAsLong, + + val input: LoadTestInput, +) { + val lock = Mutex() + + data class SingleStatistic( + val min: Double, + val max: Double, + val average: Double, + val median: Double, + val at90Percent: Double, + val at95Percent: Double, + val at99Percent: Double, + ) +} + +@Deprecated("") +data class LoadTestResponse( + val statusCode: Int, + val category: Category, + val latencyMs: Long, + val startAt: KInstantAsLong, + val endAt: KInstantAsLong, +) { + enum class Category { + Success, ServerError, ClientError, + } +} + +class LongDuplicateContainer(val value: Long) : Comparable { + companion object { + private val counter = AtomicLong(0) + } + + private val uniqueId = counter.incrementAndGet() + + // never return 0 (equals) so that we can have duplicated values in the sorted set + override fun compareTo(other: LongDuplicateContainer): Int { + return if (value < other.value) { + -1 + } else { + 1 + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is LongDuplicateContainer) return false + + if (value != other.value) return false + if (uniqueId != other.uniqueId) return false + + return true + } + + override fun hashCode(): Int { + var result = value.hashCode() + result = 31 * result + uniqueId.hashCode() + return result + } + + +} + +class LoadTestState(val input: LoadTestInput, val callId: String, val startAt: KInstant, val categorizer: (UserResponse) -> LoadTestResponse.Category) { + var numRequestsSent: AtomicInteger = AtomicInteger(0) + val latenciesMs: SortedSet = Collections.synchronizedSortedSet(sortedSetOf()) + val responsesOverResponseTime: SortedSet = Collections.synchronizedSortedSet(sortedSetOf()) + + val lock = Mutex() + + suspend fun toResult(intervalMs: Long): LoadTestResult { + val now = KInstant.now() + return lock.withLock { + val responsesGroupedByInterval = responsesOverResponseTime + .groupBy({(it.endAt!!.toEpochMilliseconds() / intervalMs) * intervalMs}, { it.userResponse }) + log.v { "responsesOverResponseTime = ${jsonMapper().writeValueAsString(responsesOverResponseTime)}" } + log.v { "responsesGroupedByInterval = ${jsonMapper().writeValueAsString(responsesGroupedByInterval)}" } + val latencies = latenciesMs.map { it.value } + LoadTestResult( + numRequestsSent = numRequestsSent.get(), + numResponses = latenciesMs.size, + latenciesMsHistogram = if (latenciesMs.size <= 1001) { + latencies + } else { + val latenciesMs = latencies + (0 .. 999).map { i -> latenciesMs[(i.toDouble() * 1000.0 / latenciesMs.size.toDouble()).roundToInt()] }.plus(latenciesMs.last()).toList() + }, + averageLatencyMs = latencies.average(), + numCatagorizedResponsesOverTime = responsesGroupedByInterval + .mapValues { (_, responses) -> responses.groupBy { categorizer(it) }.mapValues { it.value.size } } + .toSortedMap(), + latenciesMsOverTime = responsesGroupedByInterval + .mapValues { (_, result) -> + val latencies = result.asSequence().mapNotNull { + ((it.endAt ?: return@mapNotNull null) - (it.startAt ?: return@mapNotNull null)).toMilliseconds() + }.toList() + LoadTestResult.SingleStatistic( + min = latencies.min().toDouble(), + max = latencies.max().toDouble(), + average = latencies.average(), + median = latencies.atPercent(50), + at90Percent = latencies.atPercent(90), + at95Percent = latencies.atPercent(95), + at99Percent = latencies.atPercent(99), + ) + } + .toSortedMap(), + startAt = startAt, + endAt = now, + input = input, + ) + } + } +} + +data class LoadTestInput( + val numConcurrent: Int, + val timeout: KDuration, + val intendedDuration: KDuration, + + val requestId: String = "", // corresponding id of UserRequest + val requestExampleId: String = "", // corresponding id of UserRequestExample + val application: ProtocolApplication = ProtocolApplication.Http, + val requestData: RequestData? = null, +) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/model/UserResponse.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/model/UserResponse.kt index 224a3d46..16d66187 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/model/UserResponse.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/model/UserResponse.kt @@ -41,8 +41,16 @@ data class UserResponse( null, var requestData: RequestData? = null, var closeReason: String? = null, + + @Transient var type: Type = Type.Regular, + @Transient var loadTestResult: LoadTestResult? = null, + @Transient var uiVersion: String = uuidString(), ) : Identifiable { + enum class Type { + Regular, LoadTest, LoadTestChild + } + override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false @@ -96,6 +104,25 @@ class RequestData( fun isNotEmpty() = headers != null } +class UserResponseByResponseTime(val userResponse: UserResponse) : Comparable { + val endAt get() = userResponse.endAt + + // never return 0 (equals) so that we can have duplicated values in the sorted set + override fun compareTo(other: UserResponseByResponseTime): Int { + if (userResponse.endAt == null) { + return if (other.userResponse.endAt == null) { + 1 + } else { + 1 + } + } + if (other.userResponse.endAt == null) { + return -1 + } + return userResponse.endAt!!.compareTo(other.userResponse.endAt!!).let { if (it == 0) 1 else it } + } +} + val TIME_FORMAT = "yyyy-MM-dd HH:mm:ss.lll (Z)" val BODY_BLOCK_DELIMITER = "`````" diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/AbstractTransportClient.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/AbstractTransportClient.kt index fd9714f6..50be5a90 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/AbstractTransportClient.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/AbstractTransportClient.kt @@ -2,9 +2,12 @@ package com.sunnychung.application.multiplatform.hellohttp.network import com.sunnychung.application.multiplatform.hellohttp.extension.emptyToNull import com.sunnychung.application.multiplatform.hellohttp.manager.CallDataStore +import com.sunnychung.application.multiplatform.hellohttp.model.LoadTestState +import com.sunnychung.application.multiplatform.hellohttp.model.LongDuplicateContainer import com.sunnychung.application.multiplatform.hellohttp.model.RawExchange import com.sunnychung.application.multiplatform.hellohttp.model.SslConfig import com.sunnychung.application.multiplatform.hellohttp.model.UserResponse +import com.sunnychung.application.multiplatform.hellohttp.model.UserResponseByResponseTime import com.sunnychung.application.multiplatform.hellohttp.network.util.DenyAllSslCertificateManager import com.sunnychung.application.multiplatform.hellohttp.network.util.MultipleTrustCertificateManager import com.sunnychung.application.multiplatform.hellohttp.network.util.TrustAllSslCertificateManager @@ -57,7 +60,10 @@ abstract class AbstractTransportClient internal constructor(callDataStore: CallD eventSharedFlow.onEach { eventStateFlow.value = it }.launchIn(CoroutineScope(Dispatchers.IO)) } - protected fun emitEvent(callId: String, event: String) { + override fun emitEvent(callId: String, event: String, isForce: Boolean) { + if (!isForce && (callData[callId]?.let { it.fireType == UserResponse.Type.LoadTestChild } != false)) { + return + } val instant = KInstant.now() runBlocking { eventSharedFlow.emit( @@ -138,12 +144,21 @@ abstract class AbstractTransportClient internal constructor(callDataStore: CallD override fun getCallData(callId: String) = callData[callId] - protected fun createCallData(requestBodySize: Int?, requestExampleId: String, requestId: String, subprojectId: String, sslConfig: SslConfig): CallData { + override fun createCallData( + callId: String?, + requestBodySize: Int?, + requestExampleId: String, + requestId: String, + subprojectId: String, + sslConfig: SslConfig, + fireType: UserResponse.Type, + loadTestState: LoadTestState?, + ): CallData { val outgoingBytesFlow = MutableSharedFlow() val incomingBytesFlow = MutableSharedFlow() val optionalResponseSize = AtomicInteger() - val callId = uuidString() + val callId = callId ?: uuidString() val data = CallData( id = callId, @@ -157,76 +172,90 @@ abstract class AbstractTransportClient internal constructor(callDataStore: CallD outgoingBytes = outgoingBytesFlow, incomingBytes = incomingBytesFlow, optionalResponseSize = optionalResponseSize, - response = UserResponse(id = uuidString(), requestId = requestId, requestExampleId = requestExampleId), + response = UserResponse(id = uuidString(), requestId = requestId, requestExampleId = requestExampleId, type = fireType), + fireType = fireType, + loadTestState = loadTestState, cancel = {} ) callData[callId] = data - data.events - .onEach { - synchronized(data.response.rawExchange.exchanges) { - if (true || it.event == "Response completed") { // deadline fighter - data.response.rawExchange.exchanges.forEach { - it.consumePayloadBuilder() + if (fireType != UserResponse.Type.LoadTestChild) { + data.events + .onEach { + synchronized(data.response.rawExchange.exchanges) { + if (true || it.event == "Response completed") { // deadline fighter + data.response.rawExchange.exchanges.forEach { + it.consumePayloadBuilder() + } + } else { // lazy + val lastExchange = data.response.rawExchange.exchanges.lastOrNull() + lastExchange?.consumePayloadBuilder() } - } else { // lazy - val lastExchange = data.response.rawExchange.exchanges.lastOrNull() - lastExchange?.consumePayloadBuilder() + data.response.rawExchange.exchanges += RawExchange.Exchange( + instant = it.instant, + direction = RawExchange.Direction.Unspecified, + detail = it.event + ) } - data.response.rawExchange.exchanges += RawExchange.Exchange( - instant = it.instant, - direction = RawExchange.Direction.Unspecified, - detail = it.event - ) } - } - .launchIn(CoroutineScope(Dispatchers.IO)) + .launchIn(CoroutineScope(Dispatchers.IO)) - data.outgoingBytes - .onEach { - synchronized(data.response.rawExchange.exchanges) { - val lastExchange = data.response.rawExchange.exchanges.lastOrNull() - if (it is Http2Frame || lastExchange == null || lastExchange.direction != RawExchange.Direction.Outgoing) { - data.response.rawExchange.exchanges += RawExchange.Exchange( - instant = it.instant, - direction = RawExchange.Direction.Outgoing, - streamId = if (it is Http2Frame) it.streamId else null, - detail = null, - payloadBuilder = ByteArrayOutputStream(maxOf(requestBodySize ?: 0, it.payload.size + 1 * 1024 * 1024)) - ).apply { - payloadBuilder!!.write(it.payload) + data.outgoingBytes + .onEach { + synchronized(data.response.rawExchange.exchanges) { + val lastExchange = data.response.rawExchange.exchanges.lastOrNull() + if (it is Http2Frame || lastExchange == null || lastExchange.direction != RawExchange.Direction.Outgoing) { + data.response.rawExchange.exchanges += RawExchange.Exchange( + instant = it.instant, + direction = RawExchange.Direction.Outgoing, + streamId = if (it is Http2Frame) it.streamId else null, + detail = null, + payloadBuilder = ByteArrayOutputStream( + maxOf( + requestBodySize ?: 0, + it.payload.size + 1 * 1024 * 1024 + ) + ) + ).apply { + payloadBuilder!!.write(it.payload) + } + } else { + lastExchange.payloadBuilder!!.write(it.payload) + lastExchange.lastUpdateInstant = it.instant } - } else { - lastExchange.payloadBuilder!!.write(it.payload) - lastExchange.lastUpdateInstant = it.instant + log.v { it.payload.decodeToString() } } - log.v { it.payload.decodeToString() } } - } - .launchIn(CoroutineScope(Dispatchers.IO)) + .launchIn(CoroutineScope(Dispatchers.IO)) - data.incomingBytes - .onEach { - synchronized(data.response.rawExchange.exchanges) { - val lastExchange = data.response.rawExchange.exchanges.lastOrNull() - if (it is Http2Frame || lastExchange == null || lastExchange.direction != RawExchange.Direction.Incoming) { - data.response.rawExchange.exchanges += RawExchange.Exchange( - instant = it.instant, - direction = RawExchange.Direction.Incoming, - streamId = if (it is Http2Frame) it.streamId else null, - detail = null, - payloadBuilder = ByteArrayOutputStream(maxOf(optionalResponseSize.get(), it.payload.size + 1 * 1024 * 1024)) - ).apply { - payloadBuilder!!.write(it.payload) + data.incomingBytes + .onEach { + synchronized(data.response.rawExchange.exchanges) { + val lastExchange = data.response.rawExchange.exchanges.lastOrNull() + if (it is Http2Frame || lastExchange == null || lastExchange.direction != RawExchange.Direction.Incoming) { + data.response.rawExchange.exchanges += RawExchange.Exchange( + instant = it.instant, + direction = RawExchange.Direction.Incoming, + streamId = if (it is Http2Frame) it.streamId else null, + detail = null, + payloadBuilder = ByteArrayOutputStream( + maxOf( + optionalResponseSize.get(), + it.payload.size + 1 * 1024 * 1024 + ) + ) + ).apply { + payloadBuilder!!.write(it.payload) + } + } else { + lastExchange.payloadBuilder!!.write(it.payload) + lastExchange.lastUpdateInstant = it.instant } - } else { - lastExchange.payloadBuilder!!.write(it.payload) - lastExchange.lastUpdateInstant = it.instant + log.v { it.payload.decodeToString() } } - log.v { it.payload.decodeToString() } } - } - .launchIn(CoroutineScope(Dispatchers.IO)) + .launchIn(CoroutineScope(Dispatchers.IO)) + } return data } @@ -251,6 +280,10 @@ abstract class AbstractTransportClient internal constructor(callDataStore: CallD } fun executePostFlightAction(callId: String, out: UserResponse, postFlightAction: ((UserResponse) -> Unit)) { + if (callData[callId]?.let { it.fireType != UserResponse.Type.Regular } != false) { + return + } + emitEvent(callId, "Executing Post Flight Actions") try { postFlightAction(out) @@ -260,4 +293,9 @@ abstract class AbstractTransportClient internal constructor(callDataStore: CallD emitEvent(callId, "Post Flight Actions Stopped with Error -- ${e.message}") } } -} \ No newline at end of file + + fun completeResponse(callId: String, response: UserResponse) { + val call = callData[callId] ?: return + call.complete() + } +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/ApacheHttpTransportClient.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/ApacheHttpTransportClient.kt index 1d81b4ac..992a31f4 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/ApacheHttpTransportClient.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/ApacheHttpTransportClient.kt @@ -4,6 +4,7 @@ import com.sunnychung.application.multiplatform.hellohttp.extension.toApacheHttp import com.sunnychung.application.multiplatform.hellohttp.manager.NetworkClientManager import com.sunnychung.application.multiplatform.hellohttp.model.HttpConfig import com.sunnychung.application.multiplatform.hellohttp.model.HttpRequest +import com.sunnychung.application.multiplatform.hellohttp.model.LoadTestState import com.sunnychung.application.multiplatform.hellohttp.model.Protocol import com.sunnychung.application.multiplatform.hellohttp.model.ProtocolVersion import com.sunnychung.application.multiplatform.hellohttp.model.RequestData @@ -165,7 +166,8 @@ class ApacheHttpTransportClient(networkClientManager: NetworkClientManager) : Ab }) .build(), - { bytes, pos, len -> + listener@ { bytes, pos, len -> + if (callData.fireType != UserResponse.Type.Regular) return@listener println("<< " + bytes.copyOfRange(pos, pos + len).decodeToString()) runBlocking { incomingBytesFlow.emit( @@ -176,7 +178,8 @@ class ApacheHttpTransportClient(networkClientManager: NetworkClientManager) : Ab ) } }, - { bytes, pos, len -> + listener@ { bytes, pos, len -> + if (callData.fireType != UserResponse.Type.Regular) return@listener runBlocking { outgoingBytesFlow.emit( Http1Payload( @@ -306,7 +309,9 @@ class ApacheHttpTransportClient(networkClientManager: NetworkClientManager) : Ab subprojectId: String, postFlightAction: ((UserResponse) -> Unit)?, httpConfig: HttpConfig, - sslConfig: SslConfig + sslConfig: SslConfig, + fireType: UserResponse.Type, + parentLoadTestState: LoadTestState?, ): CallData { val (apacheHttpRequest, requestBodySize) = request.toApacheHttpRequest() val (apacheHttpRequestCopied, _) = request.toApacheHttpRequest() @@ -316,7 +321,9 @@ class ApacheHttpTransportClient(networkClientManager: NetworkClientManager) : Ab requestExampleId = requestExampleId, requestId = requestId, subprojectId = subprojectId, - sslConfig = sslConfig + sslConfig = sslConfig, + fireType = fireType, + loadTestState = parentLoadTestState, ) val callId = data.id @@ -503,6 +510,7 @@ class ApacheHttpTransportClient(networkClientManager: NetworkClientManager) : Ab // httpClient.close is buggy. Do not rely on it data.status = ConnectionStatus.DISCONNECTED + completeResponse(callId = callId, response = out) this.cancel(error?.let { CancellationException(it.message, it) }) } } @@ -539,6 +547,7 @@ class ApacheHttpTransportClient(networkClientManager: NetworkClientManager) : Ab data.consumePayloads() emitEvent(callId, "Response completed") + completeResponse(callId = callId, response = out) } return data } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/GraphqlSubscriptionTransportClient.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/GraphqlSubscriptionTransportClient.kt index 39222a1c..50c6938f 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/GraphqlSubscriptionTransportClient.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/GraphqlSubscriptionTransportClient.kt @@ -9,6 +9,7 @@ import com.sunnychung.application.multiplatform.hellohttp.manager.NetworkClientM import com.sunnychung.application.multiplatform.hellohttp.model.GraphqlRequestBody import com.sunnychung.application.multiplatform.hellohttp.model.HttpConfig import com.sunnychung.application.multiplatform.hellohttp.model.HttpRequest +import com.sunnychung.application.multiplatform.hellohttp.model.LoadTestState import com.sunnychung.application.multiplatform.hellohttp.model.PayloadMessage import com.sunnychung.application.multiplatform.hellohttp.model.ProtocolApplication import com.sunnychung.application.multiplatform.hellohttp.model.RequestData @@ -47,7 +48,9 @@ class GraphqlSubscriptionTransportClient(networkClientManager: NetworkClientMana subprojectId: String, postFlightAction: ((UserResponse) -> Unit)?, httpConfig: HttpConfig, - sslConfig: SslConfig + sslConfig: SslConfig, + fireType: UserResponse.Type, + parentLoadTestState: LoadTestState?, ): CallData { val payload = request.extra as GraphqlRequestBody @@ -57,6 +60,8 @@ class GraphqlSubscriptionTransportClient(networkClientManager: NetworkClientMana requestId = requestId, subprojectId = subprojectId, sslConfig = sslConfig, + fireType = fireType, + loadTestState = parentLoadTestState, ) val callId = data.id val uri: URI = request.getResolvedUri() diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/GrpcTransportClient.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/GrpcTransportClient.kt index 4c1afd15..7851e1f7 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/GrpcTransportClient.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/GrpcTransportClient.kt @@ -12,6 +12,7 @@ import com.sunnychung.application.multiplatform.hellohttp.model.GrpcApiSpec import com.sunnychung.application.multiplatform.hellohttp.model.GrpcMethod import com.sunnychung.application.multiplatform.hellohttp.model.HttpConfig import com.sunnychung.application.multiplatform.hellohttp.model.HttpRequest +import com.sunnychung.application.multiplatform.hellohttp.model.LoadTestState import com.sunnychung.application.multiplatform.hellohttp.model.PayloadMessage import com.sunnychung.application.multiplatform.hellohttp.model.Protocol import com.sunnychung.application.multiplatform.hellohttp.model.ProtocolApplication @@ -437,6 +438,8 @@ class GrpcTransportClient(networkClientManager: NetworkClientManager) : Abstract postFlightAction: ((UserResponse) -> Unit)?, httpConfig: HttpConfig, sslConfig: SslConfig, + fireType: UserResponse.Type, + parentLoadTestState: LoadTestState?, ): CallData { val uri = URI.create(request.url) @@ -446,6 +449,8 @@ class GrpcTransportClient(networkClientManager: NetworkClientManager) : Abstract requestId = requestId, subprojectId = subprojectId, sslConfig = sslConfig, + fireType = fireType, + loadTestState = parentLoadTestState, ) val extra = request.extra as GrpcRequestExtra diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/OkHttpTransportClient.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/OkHttpTransportClient.kt index d3868ee2..db04df37 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/OkHttpTransportClient.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/OkHttpTransportClient.kt @@ -4,6 +4,7 @@ import com.sunnychung.application.multiplatform.hellohttp.extension.toOkHttpRequ import com.sunnychung.application.multiplatform.hellohttp.manager.NetworkClientManager import com.sunnychung.application.multiplatform.hellohttp.model.HttpConfig import com.sunnychung.application.multiplatform.hellohttp.model.HttpRequest +import com.sunnychung.application.multiplatform.hellohttp.model.LoadTestState import com.sunnychung.application.multiplatform.hellohttp.model.SslConfig import com.sunnychung.application.multiplatform.hellohttp.model.UserResponse import com.sunnychung.application.multiplatform.hellohttp.network.okhttp.GzipDecompressionInterceptor @@ -224,7 +225,17 @@ class OkHttpTransportClient(networkClientManager: NetworkClientManager) : Abstra .build() } - override fun sendRequest(request: HttpRequest, requestExampleId: String, requestId: String, subprojectId: String, postFlightAction: ((UserResponse) -> Unit)?, httpConfig: HttpConfig, sslConfig: SslConfig): CallData { + override fun sendRequest( + request: HttpRequest, + requestExampleId: String, + requestId: String, + subprojectId: String, + postFlightAction: ((UserResponse) -> Unit)?, + httpConfig: HttpConfig, + sslConfig: SslConfig, + fireType: UserResponse.Type, + loadTestState: LoadTestState?, + ): CallData { val okHttpRequest = request.toOkHttpRequest() val data = createCallData( @@ -233,6 +244,7 @@ class OkHttpTransportClient(networkClientManager: NetworkClientManager) : Abstra requestId = requestId, subprojectId = subprojectId, sslConfig = sslConfig, + fireType = UserResponse.Type.Regular, ) val callId = data.id diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/TransportClient.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/TransportClient.kt index 4cd3307a..a60ceacf 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/TransportClient.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/TransportClient.kt @@ -2,12 +2,17 @@ package com.sunnychung.application.multiplatform.hellohttp.network import com.sunnychung.application.multiplatform.hellohttp.model.HttpConfig import com.sunnychung.application.multiplatform.hellohttp.model.HttpRequest +import com.sunnychung.application.multiplatform.hellohttp.model.LoadTestState import com.sunnychung.application.multiplatform.hellohttp.model.SslConfig import com.sunnychung.application.multiplatform.hellohttp.model.UserResponse import com.sunnychung.lib.multiplatform.kdatetime.KInstant import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onSubscription import java.util.concurrent.atomic.AtomicInteger import javax.net.ssl.KeyManager import javax.net.ssl.SSLContext @@ -16,6 +21,17 @@ import javax.net.ssl.X509TrustManager interface TransportClient { fun getCallData(callId: String): CallData? + fun createCallData( + callId: String? = null, + requestBodySize: Int?, + requestExampleId: String, + requestId: String, + subprojectId: String, + sslConfig: SslConfig, + fireType: UserResponse.Type, + loadTestState: LoadTestState? = null, + ): CallData + fun sendRequest( request: HttpRequest, requestExampleId: String, @@ -23,8 +39,12 @@ interface TransportClient { subprojectId: String, postFlightAction: ((UserResponse) -> Unit)?, httpConfig: HttpConfig, - sslConfig: SslConfig + sslConfig: SslConfig, + fireType: UserResponse.Type, + parentLoadTestState: LoadTestState?, ): CallData + + fun emitEvent(callId: String, event: String, isForce: Boolean = false) } class NetworkEvent(val callId: String, val instant: KInstant, val event: String) @@ -43,10 +63,27 @@ class CallData( val optionalResponseSize: AtomicInteger, val response: UserResponse, + // load test specific + val fireType: UserResponse.Type, + val loadTestState: LoadTestState? = null, + var cancel: (Throwable?) -> Unit, var sendPayload: (String) -> Unit = {}, var sendEndOfStream: () -> Unit = {}, -) +) { + private var isCompleted = MutableStateFlow(false) + + fun complete() { + isCompleted.value = true + } + + suspend fun awaitComplete() { + isCompleted + .onSubscription { emit(isCompleted.value) } + .filter { it == true } + .first() + } +} class LiteCallData( val id: String, diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/WebSocketTransportClient.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/WebSocketTransportClient.kt index c760c2e0..e1a6c4f2 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/WebSocketTransportClient.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/network/WebSocketTransportClient.kt @@ -3,6 +3,7 @@ package com.sunnychung.application.multiplatform.hellohttp.network import com.sunnychung.application.multiplatform.hellohttp.manager.NetworkClientManager import com.sunnychung.application.multiplatform.hellohttp.model.HttpConfig import com.sunnychung.application.multiplatform.hellohttp.model.HttpRequest +import com.sunnychung.application.multiplatform.hellohttp.model.LoadTestState import com.sunnychung.application.multiplatform.hellohttp.model.PayloadMessage import com.sunnychung.application.multiplatform.hellohttp.model.ProtocolApplication import com.sunnychung.application.multiplatform.hellohttp.model.RequestData @@ -37,7 +38,9 @@ open class WebSocketTransportClient(networkClientManager: NetworkClientManager) subprojectId: String, postFlightAction: ((UserResponse) -> Unit)?, httpConfig: HttpConfig, - sslConfig: SslConfig + sslConfig: SslConfig, + fireType: UserResponse.Type, + parentLoadTestState: LoadTestState?, ): CallData { val data = createCallData( requestBodySize = null, @@ -45,6 +48,8 @@ open class WebSocketTransportClient(networkClientManager: NetworkClientManager) requestId = requestId, subprojectId = subprojectId, sslConfig = sslConfig, + fireType = fireType, + loadTestState = parentLoadTestState, ) val callId = data.id val uri: URI = request.getResolvedUri() diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/AppView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/AppView.kt index ebff5789..6f41bb7c 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/AppView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/AppView.kt @@ -509,7 +509,8 @@ fun AppContentView() { requestExampleId = selectedRequestExampleId!!, environment = selectedEnvironment, projectId = selectedProject!!.id, - subprojectId = selectedSubproject!!.id + subprojectId = selectedSubproject!!.id, + fireType = UserResponse.Type.Regular, ) } @@ -529,6 +530,16 @@ fun AppContentView() { onClickSend = { onClickSendOrConnect() }, + onRequestLoadTest = { loadTestInput -> + networkClientManager.fireLoadTestRequests( + request = requestNonNull, + requestExampleId = selectedRequestExampleId!!, + environment = selectedEnvironment, + projectId = selectedProject!!.id, + subprojectId = selectedSubproject!!.id, + input = loadTestInput, + ) + }, onClickCancel = { networkClientManager.cancel(selectedRequestExampleId!!) }, diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/LoadTestDialog.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/LoadTestDialog.kt new file mode 100644 index 00000000..50e27282 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/LoadTestDialog.kt @@ -0,0 +1,150 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.unit.dp +import com.sunnychung.application.multiplatform.hellohttp.model.LoadTestInput +import com.sunnychung.application.multiplatform.hellohttp.util.let +import com.sunnychung.application.multiplatform.hellohttp.ux.local.LocalFont +import com.sunnychung.lib.multiplatform.kdatetime.extension.seconds + +private val FIRST_COLUMN_WIDTH = 120.dp + +@Composable +fun LoadTestDialog(isEnabled: Boolean, onDismiss: () -> Unit, onConfirm: (LoadTestInput) -> Unit) { + var numConcurrent by remember { mutableStateOf(null) } + var durationNumber by remember { mutableStateOf(null) } + var timeoutSeconds by remember { mutableStateOf(null) } + + fun onDone() { + let(numConcurrent, durationNumber, timeoutSeconds) { numConcurrent, durationNumber, timeoutSeconds -> + onConfirm( + LoadTestInput( + numConcurrent = numConcurrent, + intendedDuration = durationNumber.seconds(), + timeout = timeoutSeconds.seconds(), + ) + ) + } + } + + MainWindowDialog( + key = "LoadTestDialog", + isEnabled = isEnabled, + onDismiss = onDismiss + ) { + val focusRequester = remember { FocusRequester() } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .onPreviewKeyEvent { + if (it.key == Key.Enter && it.type == KeyEventType.KeyDown) { + onDone() + true + } else { + false + } + } + .width(IntrinsicSize.Max) + ) { + AppText( + text = "Load Test Parameters", + fontSize = LocalFont.current.dialogTitleSize, + modifier = Modifier.padding(bottom = 20.dp), + ) + + Row(verticalAlignment = Alignment.CenterVertically) { + AppText(text = "No. of concurrent users", modifier = Modifier.width(FIRST_COLUMN_WIDTH)) + AppTextFieldWithPlaceholder( + value = numConcurrent?.toString() ?: "", + onValueChange = { + if (it.isEmpty()) { + numConcurrent = null + } else { + it.toIntOrNull()?.let { numConcurrent = it } + } + }, + placeholder = { + PlaceholderText("e.g. 100") + }, + singleLine = true, + modifier = Modifier.focusRequester(focusRequester) + .defaultMinSize(minWidth = 200.dp), + ) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + AppText(text = "Duration", modifier = Modifier.width(FIRST_COLUMN_WIDTH)) + AppTextFieldWithPlaceholder( + value = durationNumber?.toString() ?: "", + onValueChange = { + if (it.isEmpty()) { + durationNumber = null + } else { + it.toIntOrNull()?.let { durationNumber = it } + } + }, + placeholder = { + PlaceholderText("e.g. 20") + }, + singleLine = true, + modifier = Modifier.defaultMinSize(minWidth = 200.dp), + ) + } + + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + AppText(text = "Timeout", modifier = Modifier.width(FIRST_COLUMN_WIDTH)) + AppTextFieldWithPlaceholder( + value = timeoutSeconds?.toString() ?: "", + onValueChange = { + if (it.isEmpty()) { + timeoutSeconds = null + } else { + it.toIntOrNull()?.let { timeoutSeconds = it } + } + }, + placeholder = { + PlaceholderText("e.g. 3") + }, + singleLine = true, + modifier = Modifier.weight(1f).padding(end = 8.dp), + ) + AppText("seconds") + } + + AppTextButton( + text = "Start", + onClick = { onDone() }, + isEnabled = listOfNotNull(numConcurrent, durationNumber, timeoutSeconds).isNotEmpty(), + modifier = Modifier.padding(top = 4.dp), + ) + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + } +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/LoadTestReportView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/LoadTestReportView.kt new file mode 100644 index 00000000..f53b226d --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/LoadTestReportView.kt @@ -0,0 +1,70 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.sunnychung.application.multiplatform.hellohttp.extension.lastOrNull +import com.sunnychung.application.multiplatform.hellohttp.model.LoadTestResponse +import com.sunnychung.application.multiplatform.hellohttp.model.LoadTestResult + +private val FIRST_COLUMN_WIDTH = 160.dp + +@Composable +fun LoadTestReportView(modifier: Modifier = Modifier, loadTestResult: LoadTestResult) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier) { + with(loadTestResult) { + Row { + AppText("Number of requests", modifier = Modifier.width(FIRST_COLUMN_WIDTH)) + AppText(numRequestsSent.toString()) + } + Row { + AppText("Number of pending responses", modifier = Modifier.width(FIRST_COLUMN_WIDTH)) + AppText((numRequestsSent - numResponses).toString()) + } + Row { + AppText("Number of Success responses", modifier = Modifier.width(FIRST_COLUMN_WIDTH)) + AppText((numCatagorizedResponsesOverTime.lastOrNull()?.get(LoadTestResponse.Category.Success)).toString()) + } + Row { + AppText("Number of Server Error responses", modifier = Modifier.width(FIRST_COLUMN_WIDTH)) + AppText((numCatagorizedResponsesOverTime.lastOrNull()?.get(LoadTestResponse.Category.ServerError)).toString()) + } + Row { + AppText("Number of Client Error responses", modifier = Modifier.width(FIRST_COLUMN_WIDTH)) + AppText((numCatagorizedResponsesOverTime.lastOrNull()?.get(LoadTestResponse.Category.ClientError)).toString()) + } + Row { + AppText("Latency Min", modifier = Modifier.width(FIRST_COLUMN_WIDTH)) + AppText((latenciesMsOverTime.lastOrNull()?.min).toString()) + } + Row { + AppText("Latency Max", modifier = Modifier.width(FIRST_COLUMN_WIDTH)) + AppText((latenciesMsOverTime.lastOrNull()?.max).toString()) + } + Row { + AppText("Latency Average", modifier = Modifier.width(FIRST_COLUMN_WIDTH)) + AppText((latenciesMsOverTime.lastOrNull()?.average).toString()) + } + Row { + AppText("Latency Median", modifier = Modifier.width(FIRST_COLUMN_WIDTH)) + AppText((latenciesMsOverTime.lastOrNull()?.median).toString()) + } + Row { + AppText("Latency 90%", modifier = Modifier.width(FIRST_COLUMN_WIDTH)) + AppText((latenciesMsOverTime.lastOrNull()?.at90Percent).toString()) + } + Row { + AppText("Latency 95%", modifier = Modifier.width(FIRST_COLUMN_WIDTH)) + AppText((latenciesMsOverTime.lastOrNull()?.at95Percent).toString()) + } + Row { + AppText("Latency 99%", modifier = Modifier.width(FIRST_COLUMN_WIDTH)) + AppText((latenciesMsOverTime.lastOrNull()?.at99Percent).toString()) + } + } + } +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/PlaceholderText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/PlaceholderText.kt new file mode 100644 index 00000000..6046577d --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/PlaceholderText.kt @@ -0,0 +1,13 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux + +import androidx.compose.runtime.Composable +import com.sunnychung.application.multiplatform.hellohttp.ux.local.LocalColor + +@Composable +fun PlaceholderText(text: String) { + val colours = LocalColor.current + AppText( + text = text, + color = colours.placeholder + ) +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt index 94aad8fe..f6b8bc35 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt @@ -59,6 +59,7 @@ import com.sunnychung.application.multiplatform.hellohttp.model.FormUrlEncodedBo import com.sunnychung.application.multiplatform.hellohttp.model.GraphqlBody import com.sunnychung.application.multiplatform.hellohttp.model.GrpcApiSpec import com.sunnychung.application.multiplatform.hellohttp.model.GrpcMethod +import com.sunnychung.application.multiplatform.hellohttp.model.LoadTestInput import com.sunnychung.application.multiplatform.hellohttp.model.MultipartBody import com.sunnychung.application.multiplatform.hellohttp.model.PayloadExample import com.sunnychung.application.multiplatform.hellohttp.model.ProtocolApplication @@ -113,6 +114,7 @@ fun RequestEditorView( onClickFetchApiSpec: () -> Unit, onClickCancelFetchApiSpec: () -> Unit, isFetchingApiSpec: Boolean, + onRequestLoadTest: (LoadTestInput) -> Unit, ) { val colors = LocalColor.current val fonts = LocalFont.current @@ -151,6 +153,7 @@ fun RequestEditorView( } var isShowCustomHttpMethodDialog by remember { mutableStateOf(false) } + var isShowLoadTestDialog by remember { mutableStateOf(false) } log.d { "RequestEditorView recompose $request" } @@ -170,6 +173,15 @@ fun RequestEditorView( }, ) + LoadTestDialog( + isEnabled = isShowLoadTestDialog, + onDismiss = { isShowLoadTestDialog = false }, + onConfirm = { loadTestInput -> + onRequestLoadTest(loadTestInput) + isShowLoadTestDialog = false + }, + ) + Column(modifier = modifier .onKeyEvent { e -> if (isEnableSendButton && e.type == KeyEventType.KeyDown && e.key == Key.Enter && !e.isAltPressed && !e.isShiftPressed) { @@ -304,7 +316,7 @@ fun RequestEditorView( ProtocolApplication.Grpc -> listOf("Copy as grpcurl command") else -> listOf("Copy as cURL command") } - } + } + listOf("Load Test") val (label, backgroundColour) = if (!connectionStatus.isConnectionActive()) { Pair(if (isOneOffRequest) "Send" else "Connect", colors.backgroundButton) } else { @@ -357,6 +369,9 @@ fun RequestEditorView( false } } + "Load Test" -> { + isShowLoadTestDialog = true + } } isSuccess }, diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt index 56707505..ac33f815 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt @@ -150,7 +150,13 @@ fun ResponseViewerView(response: UserResponse, connectionStatus: ConnectionStatu } } - val tabs = if ( + val tabs = if (response.type == UserResponse.Type.LoadTest) { + if (response.isError) { + listOf(ResponseTab.Body) + } else { + listOf(ResponseTab.Report) + } + } else if ( // TODO these conditions are poorly written. any better semantics? (response.application == ProtocolApplication.WebSocket && response.statusCode == 101) || (response.application == ProtocolApplication.Grpc && response.payloadExchanges != null) @@ -185,13 +191,18 @@ fun ResponseViewerView(response: UserResponse, connectionStatus: ConnectionStatu ResponseTab.Raw -> TransportTimelineView(protocol = response.protocol, exchange = response.rawExchange.copy(), response = response, modifier = Modifier.fillMaxSize()) + + ResponseTab.Report -> + response.loadTestResult?.let { loadTestResult -> + LoadTestReportView(loadTestResult = loadTestResult) + } ?: ResponseEmptyView(type = "report", isCommunicating = connectionStatus.isConnectionActive()) } } } } private enum class ResponseTab { - Body, Stream, Header, Raw + Body, Stream, Header, Raw, Report } @Composable diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/local/AppFont.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/local/AppFont.kt index f3ab0520..01bcac12 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/local/AppFont.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/local/AppFont.kt @@ -9,6 +9,7 @@ data class AppFont( val buttonFontSize: TextUnit, val searchInputSize: TextUnit, val supplementSize: TextUnit, + val dialogTitleSize: TextUnit, val codeEditorBodyFontSize: TextUnit, val codeEditorLineNumberFontSize: TextUnit, val streamFontSize: TextUnit = codeEditorBodyFontSize, @@ -25,6 +26,7 @@ internal fun regularFont() = AppFont( buttonFontSize = 16.sp, searchInputSize = 12.sp, supplementSize = 11.sp, + dialogTitleSize = 18.sp, codeEditorBodyFontSize = 13.sp, codeEditorLineNumberFontSize = 12.sp, diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/PercentTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/PercentTest.kt new file mode 100644 index 00000000..8a04851c --- /dev/null +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/PercentTest.kt @@ -0,0 +1,52 @@ +package com.sunnychung.application.multiplatform.hellohttp.test + +import com.sunnychung.application.multiplatform.hellohttp.extension.atPercent +import org.junit.Test +import kotlin.test.assertEquals + +class PercentTest { + @Test + fun atPercentEvenSize() { + val list = (1..100).toList() + assertEquals(50.5, list.atPercent(50)) + assertEquals(100.0, list.atPercent(100)) + assertEquals(99.0, list.atPercent(99)) + assertEquals(1.0, list.atPercent(0)) + } + + @Test + fun atPercentOldSize() { + val list = (1..99).toList() + assertEquals(50.0, list.atPercent(50)) + assertEquals(99.0, list.atPercent(100)) + assertEquals(98.0, list.atPercent(99)) + assertEquals(1.0, list.atPercent(0)) + } + + @Test + fun atPercentInTwoElements() { + val list = (1..2).toList() + assertEquals(1.5, list.atPercent(50)) + assertEquals(2.0, list.atPercent(100)) + assertEquals(2.0, list.atPercent(99)) + assertEquals(1.0, list.atPercent(0)) + } + + @Test + fun atPercentInOneElement() { + val list = (1..1).toList() + assertEquals(1.0, list.atPercent(50)) + assertEquals(1.0, list.atPercent(100)) + assertEquals(1.0, list.atPercent(99)) + assertEquals(1.0, list.atPercent(0)) + } + + @Test + fun atPercentInEmptyList() { + val list = emptyList() + assertEquals(0.0, list.atPercent(50)) + assertEquals(0.0, list.atPercent(100)) + assertEquals(0.0, list.atPercent(99)) + assertEquals(0.0, list.atPercent(0)) + } +}