From 9913b6a8b07595e54d27038bfd5e9cf496cc6d04 Mon Sep 17 00:00:00 2001 From: jvangaalen Date: Mon, 1 Jun 2026 08:04:21 +0300 Subject: [PATCH 1/2] feat: add HTTP response processing mode with lazy decompression Store the raw, possibly compressed, response body in SampleResult and decompress it on demand in getResponseData() rather than eagerly while the response is read. The original bytes stay available for checksums, and responses that are never read are never decompressed. Add a ServiceLoader-based ResponseDecoder SPI with a registry and built-in gzip, deflate, and brotli decoders, so protocols can register further content encodings. Replace the boolean "Store as MD5" option with a responseProcessingMode that controls how the body is handled: store it compressed (the new default), fetch and discard it, or store an MD5 checksum of the compressed or the decompressed stream. HTTP implementations pass the raw body and its Content-Encoding to SampleResult, so decompression runs lazily through the decoder registry. The GUI shows the mode as a plain combo box with a leading "Default" entry; selecting Default leaves the property unset so it can be inherited from HTTP Request Defaults. Old test plans keep working: when responseProcessingMode is unset, getResponseProcessingMode() falls back to the legacy HTTPSampler.md5 property, whether set on the sampler or inherited from HTTP Request Defaults. This reuses the boolean merge semantics, so an explicit md5=false on a sampler still overrides md5=true on the defaults. The GUI reflects the resolved mode and rewrites it to responseProcessingMode on save. Based on the work in apache/jmeter#6389. Co-authored-by: Vladimir Sitnikov Co-Authored-By: Claude Opus 4.8 --- .../apache/jmeter/samplers/SampleResult.java | 28 +- .../apache/jmeter/samplers/ResponseDecoder.kt | 95 +++++++ .../samplers/ResponseDecoderRegistry.kt | 188 +++++++++++++ .../samplers/decoders/DeflateDecoder.kt | 72 +++++ .../jmeter/samplers/decoders/GzipDecoder.kt | 39 +++ .../jmeter/resources/messages.properties | 8 + .../jmeter/resources/messages_fr.properties | 8 + .../samplers/ResponseDecoderRegistryTest.kt | 184 +++++++++++++ .../samplers/decoders/DeflateDecoderTest.kt | 88 ++++++ .../samplers/decoders/GzipDecoderTest.kt | 82 ++++++ .../org/apache/jmeter/junit/JMeterTest.java | 9 +- src/protocol/http/build.gradle.kts | 4 +- .../http/config/gui/HttpDefaultsGui.java | 102 ++++++- .../http/control/gui/HttpTestSampleGui.java | 101 ++++++- .../http/sampler/HTTPAbstractImpl.java | 91 +------ .../protocol/http/sampler/HTTPHC4Impl.java | 94 +------ .../protocol/http/sampler/HTTPJavaImpl.java | 70 ++--- .../http/sampler/HTTPSamplerBase.java | 251 +++++++++++++----- .../http/sampler/HTTPSamplerBaseSchema.kt | 7 + .../http/sampler/decoders/BrotliDecoder.kt | 41 +++ ...tpDefaultsGuiResponseProcessingModeTest.kt | 74 ++++++ ...TestSampleGuiResponseProcessingModeTest.kt | 97 +++++++ .../ResponseProcessingModeInheritanceTest.kt | 104 ++++++++ .../sampler/decoders/BrotliDecoderTest.kt | 62 +++++ xdocs/changes.xml | 2 + xdocs/usermanual/component_reference.xml | 25 +- 26 files changed, 1612 insertions(+), 314 deletions(-) create mode 100644 src/core/src/main/kotlin/org/apache/jmeter/samplers/ResponseDecoder.kt create mode 100644 src/core/src/main/kotlin/org/apache/jmeter/samplers/ResponseDecoderRegistry.kt create mode 100644 src/core/src/main/kotlin/org/apache/jmeter/samplers/decoders/DeflateDecoder.kt create mode 100644 src/core/src/main/kotlin/org/apache/jmeter/samplers/decoders/GzipDecoder.kt create mode 100644 src/core/src/test/kotlin/org/apache/jmeter/samplers/ResponseDecoderRegistryTest.kt create mode 100644 src/core/src/test/kotlin/org/apache/jmeter/samplers/decoders/DeflateDecoderTest.kt create mode 100644 src/core/src/test/kotlin/org/apache/jmeter/samplers/decoders/GzipDecoderTest.kt create mode 100644 src/protocol/http/src/main/kotlin/org/apache/jmeter/protocol/http/sampler/decoders/BrotliDecoder.kt create mode 100644 src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGuiResponseProcessingModeTest.kt create mode 100644 src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGuiResponseProcessingModeTest.kt create mode 100644 src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/sampler/ResponseProcessingModeInheritanceTest.kt create mode 100644 src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/sampler/decoders/BrotliDecoderTest.kt diff --git a/src/core/src/main/java/org/apache/jmeter/samplers/SampleResult.java b/src/core/src/main/java/org/apache/jmeter/samplers/SampleResult.java index 4888d250fcb..4eed1e9e9ce 100644 --- a/src/core/src/main/java/org/apache/jmeter/samplers/SampleResult.java +++ b/src/core/src/main/java/org/apache/jmeter/samplers/SampleResult.java @@ -17,6 +17,7 @@ package org.apache.jmeter.samplers; +import java.io.IOException; import java.io.Serializable; import java.io.UnsupportedEncodingException; import java.net.HttpURLConnection; @@ -161,6 +162,8 @@ public class SampleResult implements Serializable, Cloneable, Searchable { private byte[] responseData = EMPTY_BA; + private String contentEncoding; // Stores gzip/deflate encoding if response is compressed + private String responseCode = "";// Never return null private String label = "";// Never return null @@ -792,6 +795,16 @@ public void setResponseData(final String response, final String encoding) { * @return the responseData value (cannot be null) */ public byte[] getResponseData() { + if (responseData == null) { + return EMPTY_BA; + } + if (contentEncoding != null && responseData.length > 0) { + try { + return ResponseDecoderRegistry.decode(contentEncoding, responseData); + } catch (IOException e) { + log.warn("Failed to decompress response data", e); + } + } return responseData; } @@ -803,12 +816,12 @@ public byte[] getResponseData() { public String getResponseDataAsString() { try { if(responseDataAsString == null) { - responseDataAsString= new String(responseData,getDataEncodingWithDefault()); + responseDataAsString= new String(getResponseData(),getDataEncodingWithDefault()); } return responseDataAsString; } catch (UnsupportedEncodingException e) { log.warn("Using platform default as {} caused {}", getDataEncodingWithDefault(), e.getLocalizedMessage()); - return new String(responseData,Charset.defaultCharset()); // N.B. default charset is used deliberately here + return new String(getResponseData(),Charset.defaultCharset()); // N.B. default charset is used deliberately here } } @@ -1666,4 +1679,15 @@ public TestLogicalAction getTestLogicalAction() { public void setTestLogicalAction(TestLogicalAction testLogicalAction) { this.testLogicalAction = testLogicalAction; } + + /** + * Sets the response data and its contentEncoding. + * @param data The response data + * @param contentEncoding The content contentEncoding (e.g. gzip, deflate) + */ + public void setResponseData(byte[] data, String contentEncoding) { + responseData = data == null ? EMPTY_BA : data; + this.contentEncoding = contentEncoding; + responseDataAsString = null; + } } diff --git a/src/core/src/main/kotlin/org/apache/jmeter/samplers/ResponseDecoder.kt b/src/core/src/main/kotlin/org/apache/jmeter/samplers/ResponseDecoder.kt new file mode 100644 index 00000000000..98954392729 --- /dev/null +++ b/src/core/src/main/kotlin/org/apache/jmeter/samplers/ResponseDecoder.kt @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.samplers + +import org.apache.jorphan.io.DirectAccessByteArrayOutputStream +import org.apache.jorphan.reflect.JMeterService +import org.apiguardian.api.API +import java.io.ByteArrayInputStream +import java.io.InputStream + +/** + * Interface for response data decoders that handle different content encodings. + * Implementations can be automatically discovered via [java.util.ServiceLoader]. + * + * To add a custom decoder: + * 1. Implement this interface + * 2. Create `META-INF/services/org.apache.jmeter.samplers.ResponseDecoder` file + * 4. Add your implementation's fully qualified class name to the file + * + * Example decoders: gzip, deflate, brotli + * + * @since 6.0.0 + */ +@JMeterService +@API(status = API.Status.EXPERIMENTAL, since = "6.0.0") +public interface ResponseDecoder { + + /** + * Returns the content encodings handled by this decoder. + * These should match Content-Encoding header values (case-insensitive). + * + * A decoder can handle multiple encoding names (e.g., "gzip" and "x-gzip"). + * + * Examples: ["gzip", "x-gzip"], ["deflate"], ["br"] + * + * @return list of encoding names this decoder handles (must not be null or empty) + */ + public val encodings: List + + /** + * Decodes (decompresses) the given compressed data. + * + * @param compressed the compressed data to decode + * @return the decompressed data + * @throws java.io.IOException if decompression fails + */ + public fun decode(compressed: ByteArray): ByteArray { + val out = DirectAccessByteArrayOutputStream() + decodeStream(ByteArrayInputStream(compressed)).use { + it.transferTo(out) + } + return out.toByteArray() + } + + /** + * Creates a decompressing InputStream that wraps the given compressed input stream. + * This allows streaming decompression without buffering the entire response in memory. + * + * Used for scenarios like MD5 computation on decompressed data, where we want to + * compute the hash on-the-fly without storing the entire decompressed response. + * + * @param input the compressed input stream to wrap + * @return an InputStream that decompresses data as it's read + * @throws java.io.IOException if the decompressing stream cannot be created + */ + public fun decodeStream(input: InputStream): InputStream + + /** + * Returns the priority of this decoder. + * When multiple decoders are registered for the same encoding, + * the one with the highest priority is used. + * + * Default priority is 0. Built-in decoders use priority 0. + * Plugins can override built-in decoders by returning a higher priority. + * + * @return priority value (higher = preferred), default is 0 + */ + public val priority: Int + get() = 0 +} diff --git a/src/core/src/main/kotlin/org/apache/jmeter/samplers/ResponseDecoderRegistry.kt b/src/core/src/main/kotlin/org/apache/jmeter/samplers/ResponseDecoderRegistry.kt new file mode 100644 index 00000000000..f7fb3d22cb9 --- /dev/null +++ b/src/core/src/main/kotlin/org/apache/jmeter/samplers/ResponseDecoderRegistry.kt @@ -0,0 +1,188 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.samplers + +import org.apache.jmeter.samplers.decoders.DeflateDecoder +import org.apache.jmeter.samplers.decoders.GzipDecoder +import org.apache.jmeter.util.JMeterUtils +import org.apache.jorphan.reflect.LogAndIgnoreServiceLoadExceptionHandler +import org.apiguardian.api.API +import org.slf4j.LoggerFactory +import java.io.IOException +import java.io.InputStream +import java.util.Locale +import java.util.ServiceLoader +import java.util.concurrent.ConcurrentHashMap + +/** + * Registry for [ResponseDecoder] implementations. + * Provides centralized management of response decoders for different content encodings. + * + * Decoders are discovered via: + * - Built-in decoders (gzip, deflate) + * - ServiceLoader mechanism (META-INF/services) + * + * Thread-safe singleton registry. + * + * @since 6.0.0 + */ +@API(status = API.Status.EXPERIMENTAL, since = "6.0.0") +public object ResponseDecoderRegistry { + + private val log = LoggerFactory.getLogger(ResponseDecoderRegistry::class.java) + + /** + * Map of encoding name (lowercase) to decoder implementation. + * Uses ConcurrentHashMap for thread-safe access. + */ + private val decoders = ConcurrentHashMap() + + init { + // Register built-in decoders, this ensures the decoders are there even if service registration fails + registerDecoder(GzipDecoder()) + registerDecoder(DeflateDecoder()) + + // Load decoders via ServiceLoader + loadServiceLoaderDecoders() + } + + /** + * Loads decoders using ServiceLoader mechanism. + */ + private fun loadServiceLoaderDecoders() { + try { + JMeterUtils.loadServicesAndScanJars( + ResponseDecoder::class.java, + ServiceLoader.load(ResponseDecoder::class.java), + Thread.currentThread().contextClassLoader, + LogAndIgnoreServiceLoadExceptionHandler(log) + ).forEach { registerDecoder(it) } + } catch (e: Exception) { + log.error("Error loading ResponseDecoder services", e) + } + } + + /** + * Registers a decoder for all its encoding types. + * If a decoder already exists for an encoding, the one with higher priority is kept. + * + * @param decoder the decoder to register + */ + @JvmStatic + public fun registerDecoder(decoder: ResponseDecoder) { + val encodings = decoder.encodings + if (encodings.isEmpty()) { + log.warn("Decoder {} has null or empty encodings list, skipping registration", decoder.javaClass.name) + return + } + + for (encoding in encodings) { + val key = encoding.lowercase(Locale.ROOT) + + decoders.merge(key, decoder) { existing, newDecoder -> + // Keep the decoder with higher priority + if (newDecoder.priority > existing.priority) { + log.info( + "Replacing decoder for '{}': {} (priority {}) -> {} (priority {})", + encoding, + existing.javaClass.simpleName, existing.priority, + newDecoder.javaClass.simpleName, newDecoder.priority + ) + newDecoder + } else { + log.debug( + "Keeping existing decoder for '{}': {} (priority {}) over {} (priority {})", + encoding, + existing.javaClass.simpleName, existing.priority, + newDecoder.javaClass.simpleName, newDecoder.priority + ) + existing + } + } + } + } + + /** + * Decodes the given data using the decoder registered for the specified encoding. + * If no decoder is found for the encoding, returns the data unchanged. + * + * @param encoding the content encoding (e.g., "gzip", "deflate", "br") + * @param data the data to decode + * @return decoded data, or original data if no decoder found or encoding is null + * @throws IOException if decoding fails + */ + @JvmStatic + @Throws(IOException::class) + public fun decode(encoding: String?, data: ByteArray?): ByteArray { + if (encoding.isNullOrEmpty() || data == null || data.isEmpty()) { + return data ?: ByteArray(0) + } + + val decoder = decoders[encoding] ?: decoders[encoding.lowercase(Locale.ROOT)] + + if (decoder == null) { + log.debug("No decoder found for encoding '{}', returning data unchanged", encoding) + return data + } + + return decoder.decode(data) + } + + /** + * Creates a decompressing InputStream that wraps the given input stream using the decoder + * registered for the specified encoding. + * + * This enables streaming decompression without buffering the entire response in memory, + * which is useful for computing checksums on decompressed data or processing large responses. + * + * If no decoder is found for the encoding, returns the original input stream unchanged. + * + * @param encoding the content encoding (e.g., "gzip", "deflate", "br") + * @param input the input stream to wrap with decompression + * @return a decompressing InputStream, or the original stream if no decoder found or encoding is null + * @throws IOException if the decompressing stream cannot be created + * @since 6.0.0 + */ + @JvmStatic + @Throws(IOException::class) + public fun decodeStream(encoding: String?, input: InputStream): InputStream { + if (encoding.isNullOrEmpty()) { + return input + } + + val decoder = decoders[encoding] ?: decoders[encoding.lowercase(Locale.ROOT)] + + if (decoder == null) { + log.debug("No decoder found for encoding '{}', returning input stream unchanged", encoding) + return input + } + + return decoder.decodeStream(input) + } + + /** + * Checks if a decoder is registered for the given encoding. + * Primarily for testing purposes. + * + * @param encoding the encoding to check + * @return true if a decoder is registered for this encoding + */ + @JvmStatic + public fun hasDecoder(encoding: String): Boolean = + decoders.containsKey(encoding.lowercase(Locale.ROOT)) +} diff --git a/src/core/src/main/kotlin/org/apache/jmeter/samplers/decoders/DeflateDecoder.kt b/src/core/src/main/kotlin/org/apache/jmeter/samplers/decoders/DeflateDecoder.kt new file mode 100644 index 00000000000..db2653bb2ae --- /dev/null +++ b/src/core/src/main/kotlin/org/apache/jmeter/samplers/decoders/DeflateDecoder.kt @@ -0,0 +1,72 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.samplers.decoders + +import org.apache.jmeter.samplers.ResponseDecoder +import org.apache.jorphan.io.DirectAccessByteArrayOutputStream +import org.apiguardian.api.API +import java.io.ByteArrayInputStream +import java.io.IOException +import java.io.InputStream +import java.util.zip.Inflater +import java.util.zip.InflaterInputStream + +/** + * Decoder for deflate compressed response data. + * Attempts decompression with ZLIB wrapper first, falls back to raw DEFLATE if that fails. + * + * @since 6.0.0 + */ +@API(status = API.Status.INTERNAL, since = "6.0.0") +public class DeflateDecoder : ResponseDecoder { + override val encodings: List + get() = listOf("deflate") + + override fun decode(compressed: ByteArray): ByteArray { + // Try with ZLIB wrapper first + return try { + decompressWithInflater(compressed, nowrap = false) + } catch (e: IOException) { + // If that fails, try with NO_WRAP for raw DEFLATE + decompressWithInflater(compressed, nowrap = true) + } + } + + override fun decodeStream(input: InputStream): InputStream { + // For streaming, use ZLIB wrapper (nowrap=false) which is the most common case. + // The fallback to raw DEFLATE is only available in the byte array version + // since we cannot retry with a stream without buffering it first. + return InflaterInputStream(input, Inflater(false)) + } + + /** + * Decompresses data using Inflater with specified nowrap setting. + * + * @param compressed the compressed data + * @param nowrap if true, uses raw DEFLATE (no ZLIB wrapper) + * @return decompressed data + * @throws IOException if decompression fails + */ + private fun decompressWithInflater(compressed: ByteArray, nowrap: Boolean): ByteArray { + val out = DirectAccessByteArrayOutputStream() + InflaterInputStream(ByteArrayInputStream(compressed), Inflater(nowrap)).use { + it.transferTo(out) + } + return out.toByteArray() + } +} diff --git a/src/core/src/main/kotlin/org/apache/jmeter/samplers/decoders/GzipDecoder.kt b/src/core/src/main/kotlin/org/apache/jmeter/samplers/decoders/GzipDecoder.kt new file mode 100644 index 00000000000..b636f48fd80 --- /dev/null +++ b/src/core/src/main/kotlin/org/apache/jmeter/samplers/decoders/GzipDecoder.kt @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.samplers.decoders + +import org.apache.jmeter.samplers.ResponseDecoder +import org.apiguardian.api.API +import java.io.InputStream +import java.util.zip.GZIPInputStream + +/** + * Decoder for gzip compressed response data. + * Handles both "gzip" and "x-gzip" content encodings. + * + * @since 6.0.0 + */ +@API(status = API.Status.INTERNAL, since = "6.0.0") +public class GzipDecoder : ResponseDecoder { + override val encodings: List + get() = listOf("gzip", "x-gzip") + + override fun decodeStream(input: InputStream): InputStream { + return GZIPInputStream(input) + } +} diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages.properties index 3137d879e74..bb0ae82546c 100644 --- a/src/core/src/main/resources/org/apache/jmeter/resources/messages.properties +++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages.properties @@ -999,6 +999,14 @@ reportgenerator_summary_total=Total request_data=Request Data reset=Reset response_save_as_md5=Save response as MD5 hash +response_processing_checksum_decoded_md5=Checksum (MD5 of decompressed) +response_processing_checksum_encoded_md5=Checksum (MD5 of compressed) +response_processing_fetch_discard=Fetch and discard (headers only) +response_processing_mode=Processing mode\: +response_processing_mode_default=Default +response_processing_mode_tooltip=How JMeter handles the HTTP response body. Default stores it and decompresses on access; other modes discard it or store only an MD5 checksum. +response_processing_store_compressed=Store response (decompress on access) +response_processing_title=Response Processing response_time_distribution_satisfied_label=Requests having \nresponse time <= {0}ms response_time_distribution_tolerated_label= Requests having \nresponse time > {0}ms and <= {1}ms response_time_distribution_untolerated_label=Requests having \nresponse time > {0}ms diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_fr.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_fr.properties index 044d7ba8351..ab7df162b5b 100644 --- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_fr.properties +++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_fr.properties @@ -988,6 +988,14 @@ reportgenerator_top5_total=Total request_data=Donnée requête reset=Réinitialiser response_save_as_md5=Réponse en empreinte MD5 +response_processing_checksum_decoded_md5=Empreinte (MD5 du contenu décompressé) +response_processing_checksum_encoded_md5=Empreinte (MD5 du contenu compressé) +response_processing_fetch_discard=Récupérer et ignorer (en-têtes uniquement) +response_processing_mode=Mode de traitement \: +response_processing_mode_default=Par défaut +response_processing_mode_tooltip=Comment JMeter traite le corps de la réponse HTTP. Par défaut, il le stocke et le décompresse à l'accès ; les autres modes l'ignorent ou n'en stockent qu'une empreinte MD5. +response_processing_store_compressed=Stocker la réponse (décompresser à l'accès) +response_processing_title=Traitement de la réponse response_time_distribution_failed_label=Requêtes en erreur response_time_distribution_satisfied_label=Requêtes \ntemps de réponse <\= {0}ms response_time_distribution_tolerated_label=Requêtes \ntemps de réponse > {0}ms et <\= {1}ms diff --git a/src/core/src/test/kotlin/org/apache/jmeter/samplers/ResponseDecoderRegistryTest.kt b/src/core/src/test/kotlin/org/apache/jmeter/samplers/ResponseDecoderRegistryTest.kt new file mode 100644 index 00000000000..441f61b818e --- /dev/null +++ b/src/core/src/test/kotlin/org/apache/jmeter/samplers/ResponseDecoderRegistryTest.kt @@ -0,0 +1,184 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.samplers + +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.util.zip.GZIPOutputStream + +class ResponseDecoderRegistryTest { + @Test + fun testBuiltInDecodersAreRegistered() { + assertTrue(ResponseDecoderRegistry.hasDecoder("gzip"), "gzip decoder should be registered") + assertTrue(ResponseDecoderRegistry.hasDecoder("x-gzip"), "x-gzip decoder should be registered") + assertTrue(ResponseDecoderRegistry.hasDecoder("deflate"), "deflate decoder should be registered") + } + + @Test + fun testDecodeWithGzip() { + val originalText = "Hello, World! This is a test of gzip compression." + val originalData = originalText.toByteArray(Charsets.UTF_8) + + // Compress with gzip + val compressed = compressGzip(originalData) + + // Decode using registry + val decoded = ResponseDecoderRegistry.decode("gzip", compressed) + + assertArrayEquals(originalData, decoded, "Decoded data should match original") + } + + @Test + fun testDecodeWithXGzip() { + val originalText = "Testing x-gzip encoding" + val originalData = originalText.toByteArray(Charsets.UTF_8) + + // Compress with gzip (x-gzip uses same compression) + val compressed = compressGzip(originalData) + + // Decode using registry with x-gzip encoding + val decoded = ResponseDecoderRegistry.decode("x-gzip", compressed) + + assertArrayEquals(originalData, decoded, "Decoded data should match original for x-gzip") + } + + @Test + fun testDecodeWithUnknownEncoding() { + val originalData = "Test data".toByteArray(Charsets.UTF_8) + + // Decode with unknown encoding should return original data + val result = ResponseDecoderRegistry.decode("unknown-encoding", originalData) + + assertArrayEquals(originalData, result, "Unknown encoding should return data unchanged") + } + + @Test + fun testDecodeWithNullEncoding() { + val originalData = "Test data".toByteArray(Charsets.UTF_8) + + // Decode with null encoding should return original data + val result = ResponseDecoderRegistry.decode(null, originalData) + + assertArrayEquals(originalData, result, "Null encoding should return data unchanged") + } + + @Test + fun testDecodeWithEmptyData() { + val emptyData = ByteArray(0) + + val result = ResponseDecoderRegistry.decode("gzip", emptyData) + + assertArrayEquals(emptyData, result, "Empty data should return empty data") + } + + @Test + fun testCaseInsensitiveEncoding() { + val originalText = "Case insensitive test" + val originalData = originalText.toByteArray(Charsets.UTF_8) + val compressed = compressGzip(originalData) + + // Test various case combinations + val decoded1 = ResponseDecoderRegistry.decode("GZIP", compressed) + val decoded2 = ResponseDecoderRegistry.decode("GZip", compressed) + val decoded3 = ResponseDecoderRegistry.decode("gzip", compressed) + + assertArrayEquals(originalData, decoded1, "GZIP should decode correctly") + assertArrayEquals(originalData, decoded2, "GZip should decode correctly") + assertArrayEquals(originalData, decoded3, "gzip should decode correctly") + } + + @Test + fun testRegisterCustomDecoder() { + // Create a custom decoder that reverses bytes (for testing) + val reverseDecoder = object : ResponseDecoder { + override val encodings: List + get() = listOf("test-reverse") + + override fun decode(compressed: ByteArray): ByteArray = + compressed.reversedArray() + + override fun decodeStream(input: InputStream): InputStream { + TODO("Not yet implemented") + } + } + + ResponseDecoderRegistry.registerDecoder(reverseDecoder) + + val data = "ABC".toByteArray(Charsets.UTF_8) + val decoded = ResponseDecoderRegistry.decode("test-reverse", data) + + assertEquals("CBA", decoded.toString(Charsets.UTF_8), "Custom decoder should reverse bytes") + } + + @Test + fun testDecoderPriority() { + // Register a low priority decoder + val lowPriorityDecoder = object : ResponseDecoder { + override val encodings: List + get() = listOf("priority-test") + + override fun decode(compressed: ByteArray): ByteArray = + "low".toByteArray(Charsets.UTF_8) + + override fun decodeStream(input: InputStream): InputStream { + TODO("Not yet implemented") + } + + override val priority: Int + get() = 1 + } + + // Register a high priority decoder for same encoding + val highPriorityDecoder = object : ResponseDecoder { + override val encodings: List + get() = listOf("priority-test") + + override fun decode(compressed: ByteArray): ByteArray = + "high".toByteArray(Charsets.UTF_8) + + override fun decodeStream(input: InputStream): InputStream { + TODO("Not yet implemented") + } + + override val priority: Int + get() = 10 + } + + ResponseDecoderRegistry.registerDecoder(lowPriorityDecoder) + ResponseDecoderRegistry.registerDecoder(highPriorityDecoder) + + val result = ResponseDecoderRegistry.decode("priority-test", "test".toByteArray(Charsets.UTF_8)) + + assertEquals("high", result.toString(Charsets.UTF_8), "Higher priority decoder should be used") + } + + /** + * Helper method to compress data with gzip + */ + private fun compressGzip(data: ByteArray): ByteArray { + val baos = ByteArrayOutputStream() + GZIPOutputStream(baos).use { gzipOut -> + gzipOut.write(data) + } + return baos.toByteArray() + } +} diff --git a/src/core/src/test/kotlin/org/apache/jmeter/samplers/decoders/DeflateDecoderTest.kt b/src/core/src/test/kotlin/org/apache/jmeter/samplers/decoders/DeflateDecoderTest.kt new file mode 100644 index 00000000000..add44e26304 --- /dev/null +++ b/src/core/src/test/kotlin/org/apache/jmeter/samplers/decoders/DeflateDecoderTest.kt @@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.samplers.decoders + +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.io.ByteArrayOutputStream +import java.util.zip.Deflater +import java.util.zip.DeflaterOutputStream + +class DeflateDecoderTest { + private val decoder = DeflateDecoder() + + @Test + fun testGetEncodings() { + assertEquals(listOf("deflate"), decoder.encodings, "encodings") + } + + @Test + fun testGetPriority() { + assertEquals(0, decoder.priority, "Default priority should be 0") + } + + @Test + fun testDecodeDeflateWithZlibWrapper() { + val originalText = "Hello, World! This is a test message for deflate compression with ZLIB wrapper." + val originalData = originalText.toByteArray(Charsets.UTF_8) + + // Compress with ZLIB wrapper (default) + val compressed = compressDeflate(originalData, nowrap = false) + + // Decode + val decoded = decoder.decode(compressed) + + assertArrayEquals(originalData, decoded, "Decoded data should match original (ZLIB wrapper)") + } + + @Test + fun testDecodeDeflateRaw() { + val originalText = "Testing raw deflate without ZLIB wrapper." + val originalData = originalText.toByteArray(Charsets.UTF_8) + + // Compress with NO_WRAP (raw deflate) + val compressed = compressDeflate(originalData, nowrap = true) + + // Decode - should fallback to raw deflate + val decoded = decoder.decode(compressed) + + assertArrayEquals(originalData, decoded, "Decoded data should match original (raw deflate)") + } + + @Test + fun testDecodeEmptyData() { + val emptyCompressed = compressDeflate(ByteArray(0), nowrap = false) + val decoded = decoder.decode(emptyCompressed) + + assertEquals(0, decoded.size, "Empty data should decode to empty array") + } + + /** + * Helper method to compress data with deflate + * @param data the data to compress + * @param nowrap if true, uses raw deflate (no ZLIB wrapper) + */ + private fun compressDeflate(data: ByteArray, nowrap: Boolean): ByteArray { + val baos = ByteArrayOutputStream() + DeflaterOutputStream(baos, Deflater(Deflater.DEFAULT_COMPRESSION, nowrap)).use { deflaterOut -> + deflaterOut.write(data) + } + return baos.toByteArray() + } +} diff --git a/src/core/src/test/kotlin/org/apache/jmeter/samplers/decoders/GzipDecoderTest.kt b/src/core/src/test/kotlin/org/apache/jmeter/samplers/decoders/GzipDecoderTest.kt new file mode 100644 index 00000000000..9be1f83cbae --- /dev/null +++ b/src/core/src/test/kotlin/org/apache/jmeter/samplers/decoders/GzipDecoderTest.kt @@ -0,0 +1,82 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.samplers.decoders + +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import java.io.ByteArrayOutputStream +import java.util.zip.GZIPOutputStream + +class GzipDecoderTest { + private val decoder = GzipDecoder() + + @Test + fun testGetEncodings() { + assertEquals(listOf("gzip", "x-gzip"), decoder.encodings, "encodings") + } + + @Test + fun testGetPriority() { + assertEquals(0, decoder.priority, "Default priority should be 0") + } + + @Test + fun testDecodeGzipData() { + val originalText = "Hello, World! This is a test message for gzip compression." + val originalData = originalText.toByteArray(Charsets.UTF_8) + + // Compress data with gzip + val compressed = compressGzip(originalData) + + // Decode + val decoded = decoder.decode(compressed) + + assertArrayEquals(originalData, decoded, "Decoded data should match original") + assertEquals(originalText, decoded.toString(Charsets.UTF_8), "Decoded text should match original") + } + + @Test + fun testDecodeEmptyData() { + val emptyCompressed = compressGzip(ByteArray(0)) + val decoded = decoder.decode(emptyCompressed) + + assertEquals(0, decoded.size, "Empty data should decode to empty array") + } + + @Test + fun testDecodeInvalidData() { + val invalidData = "This is not gzip compressed data".toByteArray(Charsets.UTF_8) + + assertThrows(Exception::class.java) { + decoder.decode(invalidData) + } + } + + /** + * Helper method to compress data with gzip + */ + private fun compressGzip(data: ByteArray): ByteArray { + val baos = ByteArrayOutputStream() + GZIPOutputStream(baos).use { gzipOut -> + gzipOut.write(data) + } + return baos.toByteArray() + } +} diff --git a/src/dist-check/src/test/java/org/apache/jmeter/junit/JMeterTest.java b/src/dist-check/src/test/java/org/apache/jmeter/junit/JMeterTest.java index 2aca34f3e1e..8556c81a2f7 100644 --- a/src/dist-check/src/test/java/org/apache/jmeter/junit/JMeterTest.java +++ b/src/dist-check/src/test/java/org/apache/jmeter/junit/JMeterTest.java @@ -388,7 +388,14 @@ public void GUIComponents2(GuiComponentHolder componentHolder) throws Exception // TODO: support expressions in UrlConfigGui IGNORED_PROPERTIES.add(HTTPSamplerBaseSchema.INSTANCE.getFollowRedirects()); IGNORED_PROPERTIES.add(HTTPSamplerBaseSchema.INSTANCE.getAutoRedirects()); - + // responseProcessingMode is a plain combo (like implementation/ipSourceType above), so it + // can't round-trip an arbitrary ${...} expression + IGNORED_PROPERTIES.add(HTTPSamplerBaseSchema.INSTANCE.getResponseProcessingMode()); + // storeAsMD5 no longer has a UI control: the responseProcessingMode combo replaces it and + // drops the legacy property on save + @SuppressWarnings("deprecation") + PropertyDescriptor storeAsMD5 = HTTPSamplerBaseSchema.INSTANCE.getStoreAsMD5(); + IGNORED_PROPERTIES.add(storeAsMD5); } /** diff --git a/src/protocol/http/build.gradle.kts b/src/protocol/http/build.gradle.kts index af6b3482f5d..7f2d78fa474 100644 --- a/src/protocol/http/build.gradle.kts +++ b/src/protocol/http/build.gradle.kts @@ -63,10 +63,12 @@ dependencies { implementation("dnsjava:dnsjava") implementation("org.apache.httpcomponents:httpmime") implementation("org.apache.httpcomponents:httpcore") - implementation("org.brotli:dec") implementation("com.miglayout:miglayout-swing") implementation("com.fasterxml.jackson.core:jackson-core") implementation("com.fasterxml.jackson.core:jackson-databind") + implementation("org.brotli:dec") { + because("BrotliDecoder for HTTP response decompression") + } testImplementation(testFixtures(projects.src.core)) testImplementation(testFixtures(projects.src.testkitWiremock)) testImplementation("org.wiremock:wiremock") diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java index 8b49bcbb4ec..046d885d0e2 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java @@ -18,12 +18,15 @@ package org.apache.jmeter.protocol.http.config.gui; import java.awt.BorderLayout; +import java.awt.Component; import java.awt.Dimension; import java.util.Arrays; import javax.swing.BorderFactory; +import javax.swing.DefaultListCellRenderer; import javax.swing.JComboBox; import javax.swing.JLabel; +import javax.swing.JList; import javax.swing.JPanel; import javax.swing.JPasswordField; import javax.swing.JTabbedPane; @@ -58,6 +61,13 @@ public class HttpDefaultsGui extends AbstractConfigGui { private static final long serialVersionUID = 242L; + /** + * Sentinel combo item for the "Default" entry. When selected, no explicit + * {@code responseProcessingMode} is stored in the defaults element, so samplers fall back + * to the schema default rather than inheriting a value from here. + */ + private static final Object RESPONSE_PROCESSING_DEFAULT = new Object(); + private UrlConfigGui urlConfigGui; private final JBooleanPropertyEditor retrieveEmbeddedResources = new JBooleanPropertyEditor( HTTPSamplerBaseSchema.INSTANCE.getRetrieveEmbeddedResources(), @@ -66,9 +76,7 @@ public class HttpDefaultsGui extends AbstractConfigGui { HTTPSamplerBaseSchema.INSTANCE.getConcurrentDownload(), JMeterUtils.getResString("web_testing_concurrent_download")); private JTextField concurrentPool; - private final JBooleanPropertyEditor useMD5 = new JBooleanPropertyEditor( - HTTPSamplerBaseSchema.INSTANCE.getStoreAsMD5(), - JMeterUtils.getResString("response_save_as_md5")); // $NON-NLS-1$ + private final JComboBox responseProcessingMode = createResponseProcessingModeComboBox(); private JTextField embeddedAllowRE; // regular expression used to match against embedded resource URLs to allow private JTextField embeddedExcludeRE; // regular expression used to match against embedded resource URLs to discard private JTextField sourceIpAddr; // does not apply to Java implementation @@ -91,7 +99,6 @@ public HttpDefaultsGui() { retrieveEmbeddedResources, concurrentDwn, new JTextComponentBinding(concurrentPool, schema.getConcurrentDownloadPoolSize()), - useMD5, new JTextComponentBinding(embeddedAllowRE, schema.getEmbeddedUrlAllowRegex()), new JTextComponentBinding(embeddedExcludeRE, schema.getEmbeddedUrlExcludeRegex()), new JTextComponentBinding(sourceIpAddr, schema.getIpSource()), @@ -151,9 +158,27 @@ public void modifyTestElement(TestElement config) { config.removeProperty(httpSchema.getIpSourceType()); } + modifyResponseProcessingMode(config, httpSchema); + config.set(httpSchema.getImplementation(), String.valueOf(httpImplementation.getSelectedItem())); } + /** + * Stores the selected {@code responseProcessingMode}, or removes the property when the + * "Default" entry is selected so the defaults element leaves the value unset. + */ + @SuppressWarnings("deprecation") + private void modifyResponseProcessingMode(TestElement element, HTTPSamplerBaseSchema schema) { + // The combo now owns this setting, so drop the legacy md5 property (migrate it on save). + element.removeProperty(schema.getStoreAsMD5()); + Object selected = responseProcessingMode.getSelectedItem(); + if (selected instanceof HTTPSamplerBase.ResponseProcessingMode mode) { + element.set(schema.getResponseProcessingMode(), mode.getResourceKey()); + } else { + element.removeProperty(schema.getResponseProcessingMode()); + } + } + @Override public void clearGui() { super.clearGui(); @@ -167,10 +192,33 @@ public void configure(TestElement el) { urlConfigGui.configure(el); HTTPSamplerBaseSchema httpSchema = HTTPSamplerBaseSchema.INSTANCE; + configureResponseProcessingMode(el, httpSchema); sourceIpType.setSelectedIndex(samplerBase.get(httpSchema.getIpSourceType())); httpImplementation.setSelectedItem(samplerBase.getString(httpSchema.getImplementation())); } + /** + * Selects the combo entry that matches the element's stored {@code responseProcessingMode}. + * Presence is decided from the raw property value, not the schema-default-substituting getter, + * so an absent property maps to the "Default" entry. + */ + @SuppressWarnings("deprecation") + private void configureResponseProcessingMode(TestElement element, HTTPSamplerBaseSchema schema) { + String rawValue = element.getPropertyAsString(schema.getResponseProcessingMode().getName()); + HTTPSamplerBase.ResponseProcessingMode mode = + HTTPSamplerBase.ResponseProcessingMode.fromResourceKey(rawValue); + if (mode == null) { + // Reflect a legacy md5 property so the combo shows the effective mode for old test plans. + String rawMd5 = element.getPropertyAsString(schema.getStoreAsMD5().getName()); + if (!rawMd5.isEmpty()) { + mode = Boolean.parseBoolean(rawMd5) + ? HTTPSamplerBase.ResponseProcessingMode.CHECKSUM_DECODED_MD5 + : HTTPSamplerBase.ResponseProcessingMode.STORE_COMPRESSED; + } + } + responseProcessingMode.setSelectedItem(mode != null ? mode : RESPONSE_PROCESSING_DEFAULT); + } + private void init() { // WARNING: called from ctor so must not be overridden (i.e. must be private or final) setLayout(new BorderLayout(0, 5)); setBorder(makeBorder()); @@ -188,7 +236,7 @@ private void init() { // WARNING: called from ctor so must not be overridden (i. advancedPanel.add(createEmbeddedRsrcPanel()); advancedPanel.add(createSourceAddrPanel()); advancedPanel.add(getProxyServerPanel()); - advancedPanel.add(createOptionalTasksPanel()); + advancedPanel.add(createResponseProcessingPanel()); JTabbedPane tabbedPane = new JTabbedPane(); tabbedPane.add(JMeterUtils @@ -294,13 +342,43 @@ protected JPanel createSourceAddrPanel() { return sourceAddrPanel; } - protected JPanel createOptionalTasksPanel() { - // OPTIONAL TASKS - final JPanel checkBoxPanel = new VerticalPanel(); - checkBoxPanel.setBorder(BorderFactory.createTitledBorder( - JMeterUtils.getResString("optional_tasks"))); // $NON-NLS-1$ - checkBoxPanel.add(useMD5); - return checkBoxPanel; + protected JPanel createResponseProcessingPanel() { + final JPanel panel = new JPanel(new MigLayout()); + panel.setBorder(BorderFactory.createTitledBorder( + JMeterUtils.getResString("response_processing_title"))); // $NON-NLS-1$ + JLabel label = new JLabel(JMeterUtils.getResString("response_processing_mode")); // $NON-NLS-1$ + label.setToolTipText(JMeterUtils.getResString("response_processing_mode_tooltip")); // $NON-NLS-1$ + label.setLabelFor(responseProcessingMode); + panel.add(label); + panel.add(responseProcessingMode, "span"); + return panel; + } + + /** + * Builds the response-processing-mode combo: a leading "Default" entry followed by the concrete + * {@link HTTPSamplerBase.ResponseProcessingMode} values, rendered with their localised names. + */ + private static JComboBox createResponseProcessingModeComboBox() { + JComboBox comboBox = new JComboBox<>(); + comboBox.setToolTipText(JMeterUtils.getResString("response_processing_mode_tooltip")); // $NON-NLS-1$ + comboBox.addItem(RESPONSE_PROCESSING_DEFAULT); + for (HTTPSamplerBase.ResponseProcessingMode mode : HTTPSamplerBase.ResponseProcessingMode.values()) { + comboBox.addItem(mode); + } + comboBox.setRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent(JList list, Object value, int index, + boolean isSelected, boolean cellHasFocus) { + String text; + if (value instanceof HTTPSamplerBase.ResponseProcessingMode mode) { + text = JMeterUtils.getResString(mode.getResourceKey()); + } else { + text = JMeterUtils.getResString("response_processing_mode_default"); // $NON-NLS-1$ + } + return super.getListCellRendererComponent(list, text, index, isSelected, cellHasFocus); + } + }); + return comboBox; } @Override diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java index 77863a286bd..54daca9ca04 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java @@ -18,12 +18,15 @@ package org.apache.jmeter.protocol.http.control.gui; import java.awt.BorderLayout; +import java.awt.Component; import java.awt.Dimension; import java.util.Arrays; import javax.swing.BorderFactory; +import javax.swing.DefaultListCellRenderer; import javax.swing.JComboBox; import javax.swing.JLabel; +import javax.swing.JList; import javax.swing.JPanel; import javax.swing.JPasswordField; import javax.swing.JSplitPane; @@ -60,6 +63,13 @@ public class HttpTestSampleGui extends AbstractSamplerGui { private static final long serialVersionUID = 242L; + /** + * Sentinel combo item for the "Default" entry. When selected, no explicit + * {@code responseProcessingMode} is stored, so the sampler inherits the value from + * HTTP Request Defaults or falls back to the schema default. + */ + private static final Object RESPONSE_PROCESSING_DEFAULT = new Object(); + private UrlConfigGui urlConfigGui; private final JBooleanPropertyEditor retrieveEmbeddedResources = new JBooleanPropertyEditor( HTTPSamplerBaseSchema.INSTANCE.getRetrieveEmbeddedResources(), @@ -68,9 +78,7 @@ public class HttpTestSampleGui extends AbstractSamplerGui { HTTPSamplerBaseSchema.INSTANCE.getConcurrentDownload(), JMeterUtils.getResString("web_testing_concurrent_download")); private JTextField concurrentPool; - private final JBooleanPropertyEditor useMD5 = new JBooleanPropertyEditor( - HTTPSamplerBaseSchema.INSTANCE.getStoreAsMD5(), - JMeterUtils.getResString("response_save_as_md5")); // $NON-NLS-1$ + private final JComboBox responseProcessingMode = createResponseProcessingModeComboBox(); private JTextField embeddedAllowRE; // regular expression used to match against embedded resource URLs to allow private JTextField embeddedExcludeRE; // regular expression used to match against embedded resource URLs to exclude private JTextField sourceIpAddr; // does not apply to Java implementation @@ -100,7 +108,6 @@ protected HttpTestSampleGui(boolean ajp) { retrieveEmbeddedResources, concurrentDwn, new JTextComponentBinding(concurrentPool, schema.getConcurrentDownloadPoolSize()), - useMD5, new JTextComponentBinding(embeddedAllowRE, schema.getEmbeddedUrlAllowRegex()), new JTextComponentBinding(embeddedExcludeRE, schema.getEmbeddedUrlExcludeRegex()) ) @@ -132,12 +139,35 @@ public void configure(TestElement element) { final HTTPSamplerBase samplerBase = (HTTPSamplerBase) element; HTTPSamplerBaseSchema httpSchema = HTTPSamplerBaseSchema.INSTANCE; urlConfigGui.configure(element); + configureResponseProcessingMode(samplerBase, httpSchema); if (!isAJP) { sourceIpType.setSelectedIndex(samplerBase.getIpSourceType()); httpImplementation.setSelectedItem(samplerBase.getString(httpSchema.getImplementation())); } } + /** + * Selects the combo entry that matches the element's stored {@code responseProcessingMode}. + * Presence is decided from the raw property value, not the schema-default-substituting getter, + * so an absent property maps to the "Default" entry. + */ + @SuppressWarnings("deprecation") + private void configureResponseProcessingMode(TestElement element, HTTPSamplerBaseSchema schema) { + String rawValue = element.getPropertyAsString(schema.getResponseProcessingMode().getName()); + HTTPSamplerBase.ResponseProcessingMode mode = + HTTPSamplerBase.ResponseProcessingMode.fromResourceKey(rawValue); + if (mode == null) { + // Reflect a legacy md5 property so the combo shows the effective mode for old test plans. + String rawMd5 = element.getPropertyAsString(schema.getStoreAsMD5().getName()); + if (!rawMd5.isEmpty()) { + mode = Boolean.parseBoolean(rawMd5) + ? HTTPSamplerBase.ResponseProcessingMode.CHECKSUM_DECODED_MD5 + : HTTPSamplerBase.ResponseProcessingMode.STORE_COMPRESSED; + } + } + responseProcessingMode.setSelectedItem(mode != null ? mode : RESPONSE_PROCESSING_DEFAULT); + } + /** * {@inheritDoc} */ @@ -169,6 +199,7 @@ public void modifyTestElement(TestElement sampler) { final HTTPSamplerBase samplerBase = (HTTPSamplerBase) sampler; HTTPSamplerBaseSchema httpSchema = samplerBase.getSchema(); enableConcurrentDwn(); + modifyResponseProcessingMode(samplerBase, httpSchema); if (!isAJP) { String text = sourceIpAddr.getText(); if (StringUtilities.isNotEmpty(text)) { @@ -182,6 +213,22 @@ public void modifyTestElement(TestElement sampler) { } } + /** + * Stores the selected {@code responseProcessingMode}, or removes the property when the + * "Default" entry is selected so the value can still be inherited from HTTP Request Defaults. + */ + @SuppressWarnings("deprecation") + private void modifyResponseProcessingMode(TestElement element, HTTPSamplerBaseSchema schema) { + // The combo now owns this setting, so drop the legacy md5 property (migrate it on save). + element.removeProperty(schema.getStoreAsMD5()); + Object selected = responseProcessingMode.getSelectedItem(); + if (selected instanceof HTTPSamplerBase.ResponseProcessingMode mode) { + element.set(schema.getResponseProcessingMode(), mode.getResourceKey()); + } else { + element.removeProperty(schema.getResponseProcessingMode()); + } + } + /** * {@inheritDoc} */ @@ -257,7 +304,7 @@ private JPanel createAdvancedConfigPanel() { advancedPanel.add(getProxyServerPanel()); } - advancedPanel.add(createOptionalTasksPanel()); + advancedPanel.add(createResponseProcessingPanel()); return advancedPanel; } @@ -352,15 +399,43 @@ protected final JPanel getImplementationPanel(){ return implPanel; } - protected JPanel createOptionalTasksPanel() { - // OPTIONAL TASKS - final JPanel checkBoxPanel = new VerticalPanel(); - checkBoxPanel.setBorder(BorderFactory.createTitledBorder( - JMeterUtils.getResString("optional_tasks"))); // $NON-NLS-1$ - - checkBoxPanel.add(useMD5); + protected JPanel createResponseProcessingPanel() { + final JPanel panel = new JPanel(new MigLayout()); + panel.setBorder(BorderFactory.createTitledBorder( + JMeterUtils.getResString("response_processing_title"))); // $NON-NLS-1$ + JLabel label = new JLabel(JMeterUtils.getResString("response_processing_mode")); // $NON-NLS-1$ + label.setToolTipText(JMeterUtils.getResString("response_processing_mode_tooltip")); // $NON-NLS-1$ + label.setLabelFor(responseProcessingMode); + panel.add(label); + panel.add(responseProcessingMode, "span"); + return panel; + } - return checkBoxPanel; + /** + * Builds the response-processing-mode combo: a leading "Default" entry followed by the concrete + * {@link HTTPSamplerBase.ResponseProcessingMode} values, rendered with their localised names. + */ + private static JComboBox createResponseProcessingModeComboBox() { + JComboBox comboBox = new JComboBox<>(); + comboBox.setToolTipText(JMeterUtils.getResString("response_processing_mode_tooltip")); // $NON-NLS-1$ + comboBox.addItem(RESPONSE_PROCESSING_DEFAULT); + for (HTTPSamplerBase.ResponseProcessingMode mode : HTTPSamplerBase.ResponseProcessingMode.values()) { + comboBox.addItem(mode); + } + comboBox.setRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent(JList list, Object value, int index, + boolean isSelected, boolean cellHasFocus) { + String text; + if (value instanceof HTTPSamplerBase.ResponseProcessingMode mode) { + text = JMeterUtils.getResString(mode.getResourceKey()); + } else { + text = JMeterUtils.getResString("response_processing_mode_default"); // $NON-NLS-1$ + } + return super.getListCellRendererComponent(list, text, index, isSelected, cellHasFocus); + } + }); + return comboBox; } @SuppressWarnings("EnumOrdinal") diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPAbstractImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPAbstractImpl.java index 9c724a01727..8dbd6abb6c8 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPAbstractImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPAbstractImpl.java @@ -17,7 +17,6 @@ package org.apache.jmeter.protocol.http.sampler; -import java.io.BufferedInputStream; import java.io.IOException; import java.io.InputStream; import java.net.Inet4Address; @@ -42,6 +41,7 @@ import org.apache.jmeter.samplers.Interruptible; import org.apache.jmeter.samplers.SampleResult; import org.apache.jmeter.util.JMeterUtils; +import org.jspecify.annotations.Nullable; /** * Base class for HTTP implementations used by the HTTPSamplerProxy sampler. @@ -428,7 +428,7 @@ protected boolean isSuccessCode(int errorLevel) { * Closes the inputStream *

* Invokes - * {@link HTTPSamplerBase#readResponse(SampleResult, InputStream, long)} + * {@link HTTPSamplerBase#readResponse(SampleResult, InputStream, long, String)} * * @param res * sample to store information about the response into @@ -436,93 +436,12 @@ protected boolean isSuccessCode(int errorLevel) { * input stream from which to read the response * @param responseContentLength * expected input length or zero - * @return the response or the MD5 of the response * @throws IOException * if reading the result fails */ - protected byte[] readResponse(SampleResult res, InputStream instream, - int responseContentLength) throws IOException { - return readResponse(res, instream, (long)responseContentLength); - } - /** - * Read response from the input stream, converting to MD5 digest if the - * useMD5 property is set. - *

- * For the MD5 case, the result byte count is set to the size of the - * original response. - *

- * Closes the inputStream - *

- * Invokes - * {@link HTTPSamplerBase#readResponse(SampleResult, InputStream, long)} - * - * @param res - * sample to store information about the response into - * @param instream - * input stream from which to read the response - * @param responseContentLength - * expected input length or zero - * @return the response or the MD5 of the response - * @throws IOException - * if reading the result fails - */ - protected byte[] readResponse(SampleResult res, InputStream instream, - long responseContentLength) throws IOException { - return testElement.readResponse(res, instream, responseContentLength); - } - - /** - * Read response from the input stream, converting to MD5 digest if the - * useMD5 property is set. - *

- * For the MD5 case, the result byte count is set to the size of the - * original response. - *

- * Closes the inputStream - *

- * Invokes {@link HTTPSamplerBase#readResponse(SampleResult, InputStream, long)} - * - * @param res - * sample to store information about the response into - * @param in - * input stream from which to read the response - * @param contentLength - * expected input length or zero - * @return the response or the MD5 of the response - * @throws IOException - * when reading the result fails - * @deprecated use {@link HTTPAbstractImpl#readResponse(SampleResult, BufferedInputStream, long)} - */ - @Deprecated - protected byte[] readResponse(SampleResult res, BufferedInputStream in, - int contentLength) throws IOException { - return testElement.readResponse(res, in, contentLength); - } - - /** - * Read response from the input stream, converting to MD5 digest if the - * useMD5 property is set. - *

- * For the MD5 case, the result byte count is set to the size of the - * original response. - *

- * Closes the inputStream - *

- * Invokes {@link HTTPSamplerBase#readResponse(SampleResult, InputStream, long)} - * - * @param res - * sample to store information about the response into - * @param in - * input stream from which to read the response - * @param contentLength - * expected input length or zero - * @return the response or the MD5 of the response - * @throws IOException - * when reading the result fails - */ - protected byte[] readResponse(SampleResult res, BufferedInputStream in, - long contentLength) throws IOException { - return testElement.readResponse(res, in, contentLength); + protected void readResponse(SampleResult res, InputStream instream, + long responseContentLength, @Nullable String contentEncoding) throws IOException { + testElement.readResponse(res, instream, responseContentLength, contentEncoding); } /** diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC4Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC4Impl.java index 569bb164b6a..bec3565831d 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC4Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC4Impl.java @@ -20,6 +20,7 @@ import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.io.OutputStream; import java.io.UnsupportedEncodingException; import java.net.InetAddress; @@ -55,7 +56,6 @@ import org.apache.http.HttpRequest; import org.apache.http.HttpRequestInterceptor; import org.apache.http.HttpResponse; -import org.apache.http.HttpResponseInterceptor; import org.apache.http.NameValuePair; import org.apache.http.StatusLine; import org.apache.http.auth.AuthSchemeProvider; @@ -70,7 +70,6 @@ import org.apache.http.client.config.AuthSchemes; import org.apache.http.client.config.CookieSpecs; import org.apache.http.client.config.RequestConfig; -import org.apache.http.client.entity.InputStreamFactory; import org.apache.http.client.entity.UrlEncodedFormEntity; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpEntityEnclosingRequestBase; @@ -84,7 +83,6 @@ import org.apache.http.client.methods.HttpTrace; import org.apache.http.client.methods.HttpUriRequest; import org.apache.http.client.protocol.HttpClientContext; -import org.apache.http.client.protocol.ResponseContentEncoding; import org.apache.http.config.Lookup; import org.apache.http.config.Registry; import org.apache.http.config.RegistryBuilder; @@ -146,8 +144,6 @@ import org.apache.jmeter.protocol.http.control.DynamicKerberosSchemeFactory; import org.apache.jmeter.protocol.http.control.DynamicSPNegoSchemeFactory; import org.apache.jmeter.protocol.http.control.HeaderManager; -import org.apache.jmeter.protocol.http.sampler.hc.LaxDeflateInputStream; -import org.apache.jmeter.protocol.http.sampler.hc.LaxGZIPInputStream; import org.apache.jmeter.protocol.http.sampler.hc.LazyLayeredConnectionSocketFactory; import org.apache.jmeter.protocol.http.util.ConversionUtils; import org.apache.jmeter.protocol.http.util.HTTPArgument; @@ -165,7 +161,6 @@ import org.apache.jmeter.util.SSLManager; import org.apache.jorphan.util.JOrphanUtils; import org.apache.jorphan.util.StringUtilities; -import org.brotli.dec.BrotliInputStream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -194,20 +189,8 @@ public class HTTPHC4Impl extends HTTPHCAbstractImpl { private static final boolean DISABLE_DEFAULT_UA = JMeterUtils.getPropDefault("httpclient4.default_user_agent_disabled", false); - private static final boolean GZIP_RELAX_MODE = JMeterUtils.getPropDefault("httpclient4.gzip_relax_mode", false); - - private static final boolean DEFLATE_RELAX_MODE = JMeterUtils.getPropDefault("httpclient4.deflate_relax_mode", false); - private static final Logger log = LoggerFactory.getLogger(HTTPHC4Impl.class); - private static final InputStreamFactory GZIP = - instream -> new LaxGZIPInputStream(instream, GZIP_RELAX_MODE); - - private static final InputStreamFactory DEFLATE = - instream -> new LaxDeflateInputStream(instream, DEFLATE_RELAX_MODE); - - private static final InputStreamFactory BROTLI = BrotliInputStream::new; - private static final class ManagedCredentialsProvider implements CredentialsProvider { private final AuthManager authManager; private final Credentials proxyCredentials; @@ -464,55 +447,6 @@ protected HttpResponse doSendRequest( } }; - private static final String[] HEADERS_TO_SAVE = new String[]{ - "content-length", - "content-encoding", - "content-md5" - }; - - /** - * Custom implementation that backups headers related to Compressed responses - * that HC core {@link ResponseContentEncoding} removes after uncompressing - * See Bug 59401 - */ - @SuppressWarnings("UnnecessaryAnonymousClass") - private static final HttpResponseInterceptor RESPONSE_CONTENT_ENCODING = new ResponseContentEncoding(createLookupRegistry()) { - @Override - public void process(HttpResponse response, HttpContext context) - throws HttpException, IOException { - ArrayList headersToSave = null; - - final HttpEntity entity = response.getEntity(); - final HttpClientContext clientContext = HttpClientContext.adapt(context); - final RequestConfig requestConfig = clientContext.getRequestConfig(); - // store the headers if necessary - if (requestConfig.isContentCompressionEnabled() && entity != null && entity.getContentLength() != 0) { - final Header ceheader = entity.getContentEncoding(); - if (ceheader != null) { - headersToSave = new ArrayList<>(3); - for(String name : HEADERS_TO_SAVE) { - Header[] hdr = response.getHeaders(name); // empty if none - headersToSave.add(hdr); - } - } - } - - // Now invoke original parent code - super.process(response, clientContext); - // Should this be in a finally ? - if(headersToSave != null) { - for (Header[] headers : headersToSave) { - for (Header headerToRestore : headers) { - if (response.containsHeader(headerToRestore.getName())) { - break; - } - response.addHeader(headerToRestore); - } - } - } - } - }; - /** * 1 HttpClient instance per combination of (HttpClient,HttpClientKey) */ @@ -549,19 +483,6 @@ protected HTTPHC4Impl(HTTPSamplerBase testElement) { super(testElement); } - /** - * Customize to plug Brotli - * @return {@link Lookup} - */ - private static Lookup createLookupRegistry() { - return - RegistryBuilder.create() - .register("br", BROTLI) - .register("gzip", GZIP) - .register("x-gzip", GZIP) - .register("deflate", DEFLATE).build(); - } - /** * Implementation that allows GET method to have a body */ @@ -665,8 +586,15 @@ protected HTTPSampleResult sample(URL url, String method, res.setEncodingAndType(ct); } HttpEntity entity = httpResponse.getEntity(); - if (entity != null) { - res.setResponseData(readResponse(res, entity.getContent(), entity.getContentLength())); + if (entity == null) { + // No response body (e.g. HEAD request or 304 Not Modified) + res.setResponseData(new byte[0]); + } else { + try (InputStream instream = entity.getContent()) { + Header contentEncodingHeader = entity.getContentEncoding(); + String contentEncoding = contentEncodingHeader != null ? contentEncodingHeader.getValue() : null; + readResponse(res, instream, entity.getContentLength(), contentEncoding); + } } res.sampleEnd(); // Done with the sampling proper. @@ -1147,7 +1075,7 @@ private HttpClientState setupClient(HttpClientKey key, JMeterVariables jMeterVar } builder.setDefaultCredentialsProvider(credsProvider); } - builder.disableContentCompression().addInterceptorLast(RESPONSE_CONTENT_ENCODING); + builder.disableContentCompression(); // Disable automatic decompression if(BASIC_AUTH_PREEMPTIVE) { builder.addInterceptorFirst(PREEMPTIVE_AUTH_INTERCEPTOR); } else { diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java index 527ed485aad..f2505416101 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java @@ -31,7 +31,6 @@ import java.util.List; import java.util.Map; import java.util.function.Predicate; -import java.util.zip.GZIPInputStream; import org.apache.jmeter.protocol.http.control.AuthManager; import org.apache.jmeter.protocol.http.control.Authorization; @@ -219,15 +218,11 @@ protected HttpURLConnection setupConnection(URL u, String method, HTTPSampleResu /** * Reads the response from the URL connection. * - * @param conn - * URL from which to read response - * @param res - * {@link SampleResult} to read response into - * @return response content - * @exception IOException - * if an I/O exception occurs + * @param res {@link SampleResult} to read response into + * @param conn URL from which to read response + * @throws IOException if an I/O exception occurs */ - protected byte[] readResponse(HttpURLConnection conn, SampleResult res) throws IOException { + protected void readResponse(SampleResult res, HttpURLConnection conn) throws IOException { InputStream in; final long contentLength = conn.getContentLength(); @@ -236,26 +231,19 @@ protected byte[] readResponse(HttpURLConnection conn, SampleResult res) throws I log.info("Content-Length: 0, not reading http-body"); res.setResponseHeaders(getResponseHeaders(conn)); res.latencyEnd(); - return NULL_BA; + res.setResponseData(NULL_BA); + return; } - // works OK even if ContentEncoding is null - boolean gzipped = HTTPConstants.ENCODING_GZIP.equals(conn.getContentEncoding()); - - CountingInputStream instream = null; + CountingInputStream counterStream = null; try { - instream = new CountingInputStream(conn.getInputStream()); - if (gzipped) { - in = new GZIPInputStream(instream); - } else { - in = instream; - } + counterStream = new CountingInputStream(conn.getInputStream()); + in = counterStream; } catch (IOException e) { - if (! (e.getCause() instanceof FileNotFoundException)) - { + if (!(e.getCause() instanceof FileNotFoundException)) { log.error("readResponse: {}", e.toString()); Throwable cause = e.getCause(); - if (cause != null){ + if (cause != null) { log.error("Cause: {}", cause.toString()); if(cause instanceof Error error) { throw error; @@ -270,36 +258,21 @@ protected byte[] readResponse(HttpURLConnection conn, SampleResult res) throws I } res.setResponseHeaders(getResponseHeaders(conn)); res.latencyEnd(); - return NULL_BA; + res.setResponseData(NULL_BA); + return; } if(log.isInfoEnabled()) { log.info("Error Response Code: {}", conn.getResponseCode()); } - if (gzipped) { - in = new GZIPInputStream(errorStream); - } else { - in = errorStream; - } - } catch (Exception e) { - log.error("readResponse: {}", e.toString()); - Throwable cause = e.getCause(); - if (cause != null){ - log.error("Cause: {}", cause.toString()); - if(cause instanceof Error error) { - throw error; - } - } - in = conn.getErrorStream(); + in = errorStream; } - // N.B. this closes 'in' - byte[] responseData = readResponse(res, in, contentLength); - if (instream != null) { - res.setBodySize(instream.getBytesRead()); - instream.close(); + + readResponse(res, in, contentLength, conn.getContentEncoding()); + if (counterStream != null) { + res.setBodySize(counterStream.getBytesRead()); } - return responseData; } /** @@ -565,15 +538,10 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe res.setQueryString(putBody); } // Request sent. Now get the response: - byte[] responseData = readResponse(conn, res); + readResponse(res, conn); res.sampleEnd(); // Done with the sampling proper. - - // Now collect the results into the HTTPSampleResult: - - res.setResponseData(responseData); - int errorLevel = conn.getResponseCode(); String respMsg = conn.getResponseMessage(); String hdr=conn.getHeaderField(0); diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBase.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBase.java index 3105d0cf1ec..f497b9cab37 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBase.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBase.java @@ -17,7 +17,6 @@ package org.apache.jmeter.protocol.http.sampler; -import java.io.Closeable; import java.io.IOException; import java.io.InputStream; import java.io.Serializable; @@ -69,6 +68,7 @@ import org.apache.jmeter.report.utils.MetricUtils; import org.apache.jmeter.samplers.AbstractSampler; import org.apache.jmeter.samplers.Entry; +import org.apache.jmeter.samplers.ResponseDecoderRegistry; import org.apache.jmeter.samplers.SampleResult; import org.apache.jmeter.testelement.TestElement; import org.apache.jmeter.testelement.TestIterationListener; @@ -88,7 +88,7 @@ import org.apache.oro.text.MalformedCachePatternException; import org.apache.oro.text.regex.Pattern; import org.apache.oro.text.regex.Perl5Matcher; -import org.jetbrains.annotations.Nullable; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -206,6 +206,7 @@ public abstract class HTTPSamplerBase extends AbstractSampler private static final boolean IGNORE_FAILED_EMBEDDED_RESOURCES = JMeterUtils.getPropDefault("httpsampler.ignore_failed_embedded_resources", false); // $NON-NLS-1$ // default value: false + // TODO: replace with responseProcessingMode enum? private static final boolean IGNORE_EMBEDDED_RESOURCES_DATA = JMeterUtils.getPropDefault("httpsampler.embedded_resources_use_md5", false); // $NON-NLS-1$ // default value: false @@ -377,6 +378,71 @@ public static String[] getSourceTypeList() { } return displayStrings; } + + /** + * Enum for response processing modes that control how HTTP response data is handled. + * Supports different strategies for storing, discarding, or checksumming responses. + * + * @since 6.0.0 + */ + public enum ResponseProcessingMode { + /** + * Store compressed response data, decompress on-demand when accessed. + * Default mode for normal operation. Saves memory and supports lazy decompression. + */ + STORE_COMPRESSED("response_processing_store_compressed"), //$NON-NLS-1$ + + /** + * Fetch response data but discard it immediately. + * Useful when you only care about response code/headers, not the body. + * Avoids storing large responses in memory. + */ + FETCH_AND_DISCARD("response_processing_fetch_discard"), //$NON-NLS-1$ + + /** + * Compute MD5 checksum on the compressed response stream. + * Stores MD5 hash instead of full response. Useful for validating + * that compressed data hasn't been modified in transit. + */ + CHECKSUM_ENCODED_MD5("response_processing_checksum_encoded_md5"), //$NON-NLS-1$ + + /** + * Compute MD5 checksum on the decompressed response stream. + * Stores MD5 hash instead of full response. Uses streaming decompression + * to avoid buffering entire response in memory. This is the traditional + * "Store as MD5" mode from earlier versions. + */ + CHECKSUM_DECODED_MD5("response_processing_checksum_decoded_md5"); //$NON-NLS-1$ + + public final String propertyName; + + ResponseProcessingMode(String propertyName) { + this.propertyName = propertyName; + } + + public String getResourceKey() { + return propertyName; + } + + /** + * Resolves a mode from its resource key, or {@code null} if none matches. + * + * @param resourceKey the resource key (as stored in the property), may be {@code null} + * @return the matching mode, or {@code null} if {@code resourceKey} is {@code null} or unknown + */ + public static @Nullable ResponseProcessingMode fromResourceKey(@Nullable String resourceKey) { + if (resourceKey == null) { + return null; + } + for (ResponseProcessingMode mode : values()) { + if (mode.propertyName.equals(resourceKey)) { + return mode; + } + } + return null; + } + } + /** * Determine if the file should be sent as the entire Content body, * i.e. without any additional wrapping. @@ -640,11 +706,71 @@ public String getImplementation() { return get(getSchema().getImplementation()); } + /** + * Gets the response processing mode for this sampler. + * Controls how response data is handled (stored, discarded, or checksummed). + * + * @return the current response processing mode + * @since 6.0.0 + */ + @SuppressWarnings("deprecation") + public @Nullable ResponseProcessingMode getResponseProcessingMode() { + // An explicit responseProcessingMode wins, including a value inherited from HTTP Request Defaults. + String value = getPropertyAsString(getSchema().getResponseProcessingMode().getName()); + if (!value.isEmpty()) { + return ResponseProcessingMode.fromResourceKey(value); + } + // Fall back to the legacy HTTPSampler.md5 property. It may be set on this element or inherited + // from HTTP Request Defaults, so old test plans keep working until they are re-saved. This reuses + // the boolean merge semantics: an explicit md5=false on a sampler still overrides md5=true on the + // defaults, while an absent md5 inherits the defaults value. + String md5 = getPropertyAsString(getSchema().getStoreAsMD5().getName()); + if (!md5.isEmpty()) { + return Boolean.parseBoolean(md5) + ? ResponseProcessingMode.CHECKSUM_DECODED_MD5 + : ResponseProcessingMode.STORE_COMPRESSED; + } + return ResponseProcessingMode.STORE_COMPRESSED; + } + + /** + * Sets the response processing mode for this sampler. + * Controls how response data is handled (stored, discarded, or checksummed). + * + * @param mode the response processing mode to set + * @since 6.0.0 + */ + public void setResponseProcessingMode(ResponseProcessingMode mode) { + set(getSchema().getResponseProcessingMode(), mode.getResourceKey()); + } + + /** + * Returns whether this sampler should store response data as MD5 hash. + * + * @return true if MD5 mode is enabled (CHECKSUM_DECODED_MD5 or CHECKSUM_ENCODED_MD5) + * @deprecated Use {@link #getResponseProcessingMode()} instead. + * This method returns true if mode is any checksum mode. + */ + @Deprecated public boolean useMD5() { - return get(getSchema().getStoreAsMD5()); + ResponseProcessingMode mode = getResponseProcessingMode(); + return mode == ResponseProcessingMode.CHECKSUM_DECODED_MD5; } + /** + * Sets whether this sampler should store response data as MD5 hash. + * + * @param value true to enable MD5 mode (CHECKSUM_DECODED_MD5), + * false to use default mode (STORE_COMPRESSED) + * @deprecated Use {@link #setResponseProcessingMode(ResponseProcessingMode)} instead. + * This method sets mode to CHECKSUM_DECODED_MD5 if true, STORE_COMPRESSED if false. + */ + @Deprecated public void setMD5(boolean value) { + setResponseProcessingMode( + value ? ResponseProcessingMode.CHECKSUM_DECODED_MD5 : ResponseProcessingMode.STORE_COMPRESSED + ); + // Also set old property for backward compatibility with older code set(getSchema().getStoreAsMD5(), value); } @@ -1926,80 +2052,80 @@ public void testIterationStart(LoopIterationEvent event) { * @param sampleResult sample to store information about the response into * @param in input stream from which to read the response * @param length expected input length or zero - * @return the response or the MD5 of the response * @throws IOException if reading the result fails */ - public byte[] readResponse(SampleResult sampleResult, InputStream in, long length) throws IOException { + public void readResponse(SampleResult sampleResult, InputStream in, long length, @Nullable String contentEncoding) throws IOException { + ResponseProcessingMode responseProcessingMode = getResponseProcessingMode(); + if (responseProcessingMode == ResponseProcessingMode.CHECKSUM_DECODED_MD5) { + in = ResponseDecoderRegistry.decodeStream(contentEncoding, in); + contentEncoding = null; // already decoded + } - DirectAccessByteArrayOutputStream w = null; - try (Closeable ignore = in) { // NOSONAR No try with resource as performance is critical here - byte[] readBuffer = new byte[8192]; // 8kB is the (max) size to have the latency ('the first packet') - int bufferSize = 32;// Enough for MD5 + // 8kB is the (max) size to have the latency ('the first packet') + byte[] readBuffer = new byte[Math.toIntExact(length > 0 ? Math.min(length, 8192) : 8192)]; - MessageDigest md = null; - boolean knownResponseLength = length > 0;// may also happen if long value > int.max - if (useMD5()) { + MessageDigest md = null; + DirectAccessByteArrayOutputStream w = null; + switch (responseProcessingMode) { + case FETCH_AND_DISCARD -> { + } + case STORE_COMPRESSED -> { + w = new DirectAccessByteArrayOutputStream(Math.toIntExact(length > 0 ? Math.min(length, MAX_BUFFER_SIZE) : MAX_BUFFER_SIZE)); + } + case CHECKSUM_DECODED_MD5, CHECKSUM_ENCODED_MD5 -> { try { md = MessageDigest.getInstance("MD5"); //$NON-NLS-1$ } catch (NoSuchAlgorithmException e) { - log.error("Should not happen - could not find MD5 digest", e); - } - } else { - if (!knownResponseLength) { - bufferSize = 4 * 1024; - } else { - bufferSize = (int) Math.min(MAX_BUFFER_SIZE, length); + throw new IllegalStateException("MD5 digest algorithm not supported", e); } } + } - - int bytesReadInBuffer = 0; - long totalBytes = 0; - boolean first = true; - boolean storeInBOS = true; - while ((bytesReadInBuffer = in.read(readBuffer)) > -1) { - if (first) { - sampleResult.latencyEnd(); - first = false; - if (md == null) { - w = new DirectAccessByteArrayOutputStream(knownResponseLength ? bufferSize : 8192); - } - } - - if (md == null) { - if(storeInBOS) { - if(MAX_BYTES_TO_STORE_PER_REQUEST <= 0 || - (totalBytes+bytesReadInBuffer<=MAX_BYTES_TO_STORE_PER_REQUEST) || - JMeterContextService.getContext().isRecording()) { - w.write(readBuffer, 0, bytesReadInBuffer); - } else { - log.debug("Big response, truncating it to {} bytes", MAX_BYTES_TO_STORE_PER_REQUEST); - w.write(readBuffer, 0, (int)(MAX_BYTES_TO_STORE_PER_REQUEST-totalBytes)); - storeInBOS = false; - } - } - } else { - md.update(readBuffer, 0, bytesReadInBuffer); - } - totalBytes += bytesReadInBuffer; + int bytesReadInBuffer; + long totalBytes = 0; + boolean first = true; + boolean storeInBOS = true; + while ((bytesReadInBuffer = in.read(readBuffer)) != -1) { + if (bytesReadInBuffer == 0) { + continue; } - - if (first) { // Bug 46838 - if there was no data, still need to set latency + if (first) { sampleResult.latencyEnd(); - return new byte[0]; + first = false; } - if (md == null) { - return w.toByteArray(); - } else { - byte[] md5Result = md.digest(); - sampleResult.setBytes(totalBytes); - return JOrphanUtils.baToHexBytes(md5Result); + if (md != null) { + md.update(readBuffer, 0, bytesReadInBuffer); + } else if (storeInBOS && w != null) { + if (MAX_BYTES_TO_STORE_PER_REQUEST <= 0 || + (totalBytes + bytesReadInBuffer <= MAX_BYTES_TO_STORE_PER_REQUEST) || + JMeterContextService.getContext().isRecording()) { + w.write(readBuffer, 0, bytesReadInBuffer); + } else { + log.debug("Big response, truncating it to {} bytes", MAX_BYTES_TO_STORE_PER_REQUEST); + w.write(readBuffer, 0, (int) (MAX_BYTES_TO_STORE_PER_REQUEST - totalBytes)); + storeInBOS = false; + } } + totalBytes += bytesReadInBuffer; + } + + if (first) { // Bug 46838 - if there was no data, still need to set latency + sampleResult.latencyEnd(); + sampleResult.setResponseData(new byte[0]); + return; + } - } finally { - JOrphanUtils.closeQuietly(w); + byte[] resultBody; + if (w != null) { + resultBody = w.toByteArray(); + } else if (md != null) { + byte[] md5Result = md.digest(); + resultBody = JOrphanUtils.baToHexBytes(md5Result); + } else { + resultBody = new byte[0]; } + sampleResult.setResponseData(resultBody, contentEncoding); } /** @@ -2145,7 +2271,10 @@ private static class ASyncSample implements Callable { CookieManager clonedCookieManager = (CookieManager) cookieManager.clone(); this.sampler.setCookieManagerProperty(clonedCookieManager); } - this.sampler.setMD5(this.sampler.useMD5() || IGNORE_EMBEDDED_RESOURCES_DATA); + ResponseProcessingMode responseProcessingMode = base.getResponseProcessingMode(); + this.sampler.setResponseProcessingMode( + IGNORE_EMBEDDED_RESOURCES_DATA ? ResponseProcessingMode.CHECKSUM_DECODED_MD5 : responseProcessingMode + ); this.jmeterContextOfParentThread = JMeterContextService.getContext(); } diff --git a/src/protocol/http/src/main/kotlin/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBaseSchema.kt b/src/protocol/http/src/main/kotlin/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBaseSchema.kt index 89bb2f58e28..d5e1b6cdf16 100644 --- a/src/protocol/http/src/main/kotlin/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBaseSchema.kt +++ b/src/protocol/http/src/main/kotlin/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBaseSchema.kt @@ -123,9 +123,16 @@ public abstract class HTTPSamplerBaseSchema : TestElementSchema() { public val embeddedUrlExcludeRegex: StringPropertyDescriptor by string("HTTPSampler.embedded_url_exclude_re") + @Deprecated(message = "Use responseProcessingMode instead") public val storeAsMD5: BooleanPropertyDescriptor by boolean("HTTPSampler.md5", default = false) + public val responseProcessingMode: StringPropertyDescriptor + by string( + "HTTPSampler.responseProcessingMode", + default = HTTPSamplerBase.ResponseProcessingMode.STORE_COMPRESSED.resourceKey + ) + public val postBodyRaw: BooleanPropertyDescriptor by boolean("HTTPSampler.postBodyRaw", default = false) diff --git a/src/protocol/http/src/main/kotlin/org/apache/jmeter/protocol/http/sampler/decoders/BrotliDecoder.kt b/src/protocol/http/src/main/kotlin/org/apache/jmeter/protocol/http/sampler/decoders/BrotliDecoder.kt new file mode 100644 index 00000000000..3e34d13972d --- /dev/null +++ b/src/protocol/http/src/main/kotlin/org/apache/jmeter/protocol/http/sampler/decoders/BrotliDecoder.kt @@ -0,0 +1,41 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.protocol.http.sampler.decoders + +import com.google.auto.service.AutoService +import org.apache.jmeter.samplers.ResponseDecoder +import org.apiguardian.api.API +import org.brotli.dec.BrotliInputStream +import java.io.InputStream + +/** + * Decoder for Brotli compressed response data. + * Handles "br" content encoding. + * + * @since 6.0.0 + */ +@AutoService(ResponseDecoder::class) +@API(status = API.Status.INTERNAL, since = "6.0.0") +public class BrotliDecoder : ResponseDecoder { + override val encodings: List + get() = listOf("br") + + override fun decodeStream(input: InputStream): InputStream { + return BrotliInputStream(input) + } +} diff --git a/src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGuiResponseProcessingModeTest.kt b/src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGuiResponseProcessingModeTest.kt new file mode 100644 index 00000000000..eb5875002b9 --- /dev/null +++ b/src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGuiResponseProcessingModeTest.kt @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.protocol.http.config.gui + +import org.apache.jmeter.config.ConfigTestElement +import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase.ResponseProcessingMode +import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBaseSchema +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** + * Round-trips the response-processing-mode combo of [HttpDefaultsGui] through + * `configure` and `modifyTestElement`. + */ +class HttpDefaultsGuiResponseProcessingModeTest { + private val modePropertyName = HTTPSamplerBaseSchema.INSTANCE.responseProcessingMode.name + + private lateinit var gui: HttpDefaultsGui + + @BeforeEach + fun setUp() { + gui = HttpDefaultsGui() + } + + // createTestElement() yields a fully populated defaults element (with the URL config the GUI expects). + private fun newDefaults() = gui.createTestElement() as ConfigTestElement + + @Test + fun defaultEntryMapsToAbsentProperty() { + // A fresh defaults element has no responseProcessingMode property, so the combo shows "Default". + gui.configure(newDefaults()) + + val target = newDefaults() + gui.modifyTestElement(target) + + assertEquals( + "", + target.getPropertyAsString(modePropertyName), + "selecting 'Default' must leave responseProcessingMode absent" + ) + } + + @Test + fun concreteModeRoundTripsThroughResourceKey() { + val source = newDefaults() + source.setProperty(modePropertyName, ResponseProcessingMode.FETCH_AND_DISCARD.resourceKey) + gui.configure(source) + + val target = newDefaults() + gui.modifyTestElement(target) + + assertEquals( + ResponseProcessingMode.FETCH_AND_DISCARD.resourceKey, + target.getPropertyAsString(modePropertyName), + "a concrete mode must round-trip as its resource key" + ) + } +} diff --git a/src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGuiResponseProcessingModeTest.kt b/src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGuiResponseProcessingModeTest.kt new file mode 100644 index 00000000000..8e0a9691ae0 --- /dev/null +++ b/src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGuiResponseProcessingModeTest.kt @@ -0,0 +1,97 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.protocol.http.control.gui + +import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase.ResponseProcessingMode +import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBaseSchema +import org.apache.jmeter.protocol.http.sampler.HTTPSamplerProxy +import org.apache.jmeter.testelement.property.BooleanProperty +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** + * Round-trips the response-processing-mode combo of [HttpTestSampleGui] through + * `configure` and `modifyTestElement`. + */ +class HttpTestSampleGuiResponseProcessingModeTest { + private val modePropertyName = HTTPSamplerBaseSchema.INSTANCE.responseProcessingMode.name + private val md5PropertyName = "HTTPSampler.md5" + + private lateinit var gui: HttpTestSampleGui + + @BeforeEach + fun setUp() { + gui = HttpTestSampleGui() + } + + @Test + fun defaultEntryMapsToAbsentProperty() { + // A fresh sampler has no responseProcessingMode property, so the combo shows "Default". + gui.configure(HTTPSamplerProxy()) + + val target = HTTPSamplerProxy() + gui.modifyTestElement(target) + + assertEquals( + "", + target.getPropertyAsString(modePropertyName), + "selecting 'Default' must leave responseProcessingMode absent" + ) + } + + @Test + fun concreteModeRoundTripsThroughResourceKey() { + val source = HTTPSamplerProxy() + source.setProperty(modePropertyName, ResponseProcessingMode.FETCH_AND_DISCARD.resourceKey) + gui.configure(source) + + val target = HTTPSamplerProxy() + gui.modifyTestElement(target) + + assertEquals( + ResponseProcessingMode.FETCH_AND_DISCARD.resourceKey, + target.getPropertyAsString(modePropertyName), + "a concrete mode must round-trip as its resource key" + ) + assertEquals(ResponseProcessingMode.FETCH_AND_DISCARD, target.responseProcessingMode) + } + + @Test + fun legacyMd5IsReflectedAndMigratedOnSave() { + // An old test plan has only HTTPSampler.md5=true; the combo should show the checksum mode. + val source = HTTPSamplerProxy() + source.setProperty(BooleanProperty(md5PropertyName, true)) + gui.configure(source) + + val target = HTTPSamplerProxy() + target.setProperty(BooleanProperty(md5PropertyName, true)) + gui.modifyTestElement(target) + + assertEquals( + ResponseProcessingMode.CHECKSUM_DECODED_MD5.resourceKey, + target.getPropertyAsString(modePropertyName), + "legacy md5=true should be reflected and saved as the checksum mode" + ) + assertEquals( + "", + target.getPropertyAsString(md5PropertyName), + "the legacy md5 property should be dropped on save" + ) + } +} diff --git a/src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/sampler/ResponseProcessingModeInheritanceTest.kt b/src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/sampler/ResponseProcessingModeInheritanceTest.kt new file mode 100644 index 00000000000..68fe4485bfe --- /dev/null +++ b/src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/sampler/ResponseProcessingModeInheritanceTest.kt @@ -0,0 +1,104 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.protocol.http.sampler + +import org.apache.jmeter.config.ConfigTestElement +import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase.ResponseProcessingMode +import org.apache.jmeter.testelement.property.BooleanProperty +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** + * Verifies how `responseProcessingMode` is resolved: an explicit value wins, otherwise the legacy + * `HTTPSampler.md5` property (own or inherited from HTTP Request Defaults) is used, otherwise the + * default applies. This keeps old test plans working and preserves the boolean override semantics. + */ +class ResponseProcessingModeInheritanceTest { + private val md5 = "HTTPSampler.md5" + private val mode = HTTPSamplerBaseSchema.INSTANCE.responseProcessingMode.name + + @Test + fun legacyMd5TrueResolvesToChecksumDecodedMd5() { + val sampler = HTTPSamplerProxy() + sampler.setProperty(BooleanProperty(md5, true)) + assertEquals(ResponseProcessingMode.CHECKSUM_DECODED_MD5, sampler.responseProcessingMode) + } + + @Test + fun legacyMd5FalseResolvesToStoreCompressed() { + val sampler = HTTPSamplerProxy() + sampler.setProperty(BooleanProperty(md5, false)) + assertEquals(ResponseProcessingMode.STORE_COMPRESSED, sampler.responseProcessingMode) + } + + @Test + fun unsetResolvesToStoreCompressed() { + assertEquals(ResponseProcessingMode.STORE_COMPRESSED, HTTPSamplerProxy().responseProcessingMode) + } + + @Test + fun defaultsMd5TrueIsInheritedWhenSamplerHasNoSetting() { + val defaults = ConfigTestElement() + defaults.setProperty(BooleanProperty(md5, true)) + + val sampler = HTTPSamplerProxy() + sampler.addTestElement(defaults) + + assertEquals( + ResponseProcessingMode.CHECKSUM_DECODED_MD5, + sampler.responseProcessingMode, + "a sampler without its own setting must inherit md5=true from HTTP Request Defaults" + ) + } + + @Test + fun samplerMd5FalseOverridesDefaultsMd5True() { + val defaults = ConfigTestElement() + defaults.setProperty(BooleanProperty(md5, true)) + + val sampler = HTTPSamplerProxy() + sampler.setProperty(BooleanProperty(md5, false)) + sampler.addTestElement(defaults) + + assertEquals( + ResponseProcessingMode.STORE_COMPRESSED, + sampler.responseProcessingMode, + "an explicit md5=false on the sampler must override md5=true from the defaults" + ) + } + + @Test + fun samplerInheritsProcessingModeFromDefaults() { + val defaults = ConfigTestElement() + defaults.setProperty(mode, ResponseProcessingMode.FETCH_AND_DISCARD.resourceKey) + + val sampler = HTTPSamplerProxy() + sampler.addTestElement(defaults) + + assertEquals(ResponseProcessingMode.FETCH_AND_DISCARD, sampler.responseProcessingMode) + } + + @Test + fun explicitModeWinsOverLegacyMd5() { + val sampler = HTTPSamplerProxy() + sampler.setProperty(BooleanProperty(md5, true)) + sampler.setProperty(mode, ResponseProcessingMode.FETCH_AND_DISCARD.resourceKey) + + assertEquals(ResponseProcessingMode.FETCH_AND_DISCARD, sampler.responseProcessingMode) + } +} diff --git a/src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/sampler/decoders/BrotliDecoderTest.kt b/src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/sampler/decoders/BrotliDecoderTest.kt new file mode 100644 index 00000000000..9eb2bcc11b9 --- /dev/null +++ b/src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/sampler/decoders/BrotliDecoderTest.kt @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.protocol.http.sampler.decoders + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import java.io.IOException +import java.util.Base64 + +/** + * Basic tests for BrotliDecoder. + * Full integration tests for brotli decompression are covered by HTTP sampler tests. + */ +class BrotliDecoderTest { + private val decoder = BrotliDecoder() + + @Test + fun testGetEncodings() { + assertEquals(listOf("br"), decoder.encodings, "encodings") + } + + @Test + fun testGetPriority() { + assertEquals(0, decoder.priority, "Default priority should be 0") + } + + @Test + fun testDecodeBrotliData() { + // Pre-compressed "Hello World" with Brotli + // Generated using: printf 'Hello World' | brotli | base64 + val compressed = Base64.getDecoder().decode("DwWASGVsbG8gV29ybGQD") + + val decoded = decoder.decode(compressed) + + assertEquals("Hello World", decoded.toString(Charsets.UTF_8), "Decoded text should match original") + } + + @Test + fun testDecodeInvalidData() { + val invalidData = "This is not brotli compressed data".toByteArray(Charsets.UTF_8) + + assertThrows(IOException::class.java) { + decoder.decode(invalidData) + } + } +} diff --git a/xdocs/changes.xml b/xdocs/changes.xml index 988377c81b1..486734356dd 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -81,6 +81,7 @@ Summary

  • 6250Avoid adding "; charset=" automatically to multipart/form-data requests to align behavior with modern HTTP clients.
  • 6080Preserve the original HTTP method when following 307 and 308 redirects according to the HTTP specification. Contributed by LeeJiWon (github.com/dlwldnjs1009)
  • 62676268Add a space between key and value after : in View Results Tree > Sampler result tab for better readability.
  • +
  • 63886389Add a Processing mode option to HTTP Request and HTTP Request Defaults that controls how the response body is handled: store it and decompress it lazily when accessed (the new default), fetch and discard it, or store an MD5 checksum of the compressed or decompressed response. The former "Save response as MD5 hash?" option maps to the decompressed-MD5 mode, and existing test plans are upgraded automatically. Contributed by Joerek van Gaalen (github.com/jgaalen)
  • Timers, Assertions, Config, Pre- & Post-Processors

    @@ -123,6 +124,7 @@ Summary

    • Raul Almeida (github.com/ratacolita)
    • +
    • Joerek van Gaalen (github.com/jgaalen)
    • Pasquale Pochop (github.com/pochopsp)
    • Gabriele Coletta (github.com/gdmg92)
    • Patrick Uiterwijk (patrick at puiterwijk.org)
    • diff --git a/xdocs/usermanual/component_reference.xml b/xdocs/usermanual/component_reference.xml index 663b24fe6e4..fd500c7e05c 100644 --- a/xdocs/usermanual/component_reference.xml +++ b/xdocs/usermanual/component_reference.xml @@ -343,10 +343,23 @@ so unsafe characters may need to be encoded to avoid errors such as URISyn and send HTTP/HTTPS requests for all images, Java applets, JavaScript files, CSSs, etc. referenced in the file. See below for more details. - - If this is selected, then the response is not stored in the sample result. - Instead, the 32 character MD5 hash of the data is calculated and stored instead. - This is intended for testing large amounts of data. + + Controls how the response body is handled. The options are: +
        +
      • Default - leave the mode unset, so the sampler inherits it from + if one is set, and otherwise behaves like + Store response (decompress on access).
      • +
      • Store response (decompress on access) - store the response as received and + decompress it only when the body is read (for example by an assertion, post-processor, or + listener). Responses that are never read are never decompressed, which saves CPU and memory.
      • +
      • Fetch and discard (headers only) - read the response but do not store the body. + Use this when you only need the response code and headers.
      • +
      • Checksum (MD5 of compressed) - store the 32-character MD5 hash of the compressed + response instead of the body.
      • +
      • Checksum (MD5 of decompressed) - store the 32-character MD5 hash of the + decompressed response instead of the body. This is the former "Save response as MD5 hash" + behaviour, and test plans that used it are upgraded to this mode when loaded.
      • +
      If present, this must be a regular expression that is used to match against any embedded URLs found. @@ -3948,6 +3961,10 @@ and send HTTP/HTTPS requests for all images, Java applets, JavaScript files, CSS So if you don't want to download PNG or SVG files from any source, use the expression: .*\.(?i:svg|png) + + Default response-processing mode for HTTP samplers that leave theirs set to Default. + See "Processing mode:" under for the available modes. + Note: radio buttons only have two states - on or off. From 53e8bb510f11985ac23d0b5984d0af31abcbcd26 Mon Sep 17 00:00:00 2001 From: Vladimir Sitnikov Date: Mon, 1 Jun 2026 09:24:37 +0300 Subject: [PATCH 2/2] feat(core): add TestElementUpgrader SPI for load-time property upgrades SaveService can rename keys and classes on load via NameUpdater, but it cannot transform property *values*. Add a TestElementUpgrader service interface, discovered with ServiceLoader, that upgrades legacy properties on a loaded test plan. SaveService runs all registered upgraders to a fix point after reading a plan, so chained upgrades converge without numbering the migrations. Add the first upgrader: an HTTP-side ResponseProcessingModeUpgrader maps the legacy HTTPSampler.md5 flag to responseProcessingMode. It keys off the property name, so it upgrades both HTTP samplers and HTTP Request Defaults (a ConfigTestElement), which core cannot reference directly. This normalises old plans into the new property on load and on re-save. Runtime correctness for plans built another way still relies on getResponseProcessingMode() falling back to the legacy flag. Co-Authored-By: Claude Opus 4.8 --- .../org/apache/jmeter/save/SaveService.java | 7 +- .../apache/jmeter/save/TestElementUpgrader.kt | 55 ++++++++ .../jmeter/save/TestElementUpgraders.kt | 125 ++++++++++++++++++ .../sampler/ResponseProcessingModeUpgrader.kt | 57 ++++++++ ...ResponseProcessingModeUpgradeOnLoadTest.kt | 55 ++++++++ .../ResponseProcessingModeUpgraderTest.kt | 74 +++++++++++ 6 files changed, 372 insertions(+), 1 deletion(-) create mode 100644 src/core/src/main/kotlin/org/apache/jmeter/save/TestElementUpgrader.kt create mode 100644 src/core/src/main/kotlin/org/apache/jmeter/save/TestElementUpgraders.kt create mode 100644 src/protocol/http/src/main/kotlin/org/apache/jmeter/protocol/http/sampler/ResponseProcessingModeUpgrader.kt create mode 100644 src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/sampler/ResponseProcessingModeUpgradeOnLoadTest.kt create mode 100644 src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/sampler/ResponseProcessingModeUpgraderTest.kt diff --git a/src/core/src/main/java/org/apache/jmeter/save/SaveService.java b/src/core/src/main/java/org/apache/jmeter/save/SaveService.java index 86c16fc626d..d7047b0eddd 100644 --- a/src/core/src/main/java/org/apache/jmeter/save/SaveService.java +++ b/src/core/src/main/java/org/apache/jmeter/save/SaveService.java @@ -455,7 +455,12 @@ private static HashTree readTree(InputStream inputStream, File file) log.error("Problem loading XML: see above."); return null; } - return wrapper.testPlan; + HashTree testPlan = wrapper.testPlan; + // Upgrade legacy property values (e.g. HTTPSampler.md5 -> responseProcessingMode) right after + // load, so the in-memory tree and any re-save use the current representation. NameUpdater only + // renames keys/classes, so value transforms live in TestElementUpgrader services instead. + TestElementUpgraders.upgrade(testPlan); + return testPlan; } catch (CannotResolveClassException | ConversionException | NoClassDefFoundError e) { if(file != null) { throw new IllegalArgumentException("Problem loading XML from:'"+file.getAbsolutePath()+"'. \nCause:\n"+ diff --git a/src/core/src/main/kotlin/org/apache/jmeter/save/TestElementUpgrader.kt b/src/core/src/main/kotlin/org/apache/jmeter/save/TestElementUpgrader.kt new file mode 100644 index 00000000000..d478d52e71c --- /dev/null +++ b/src/core/src/main/kotlin/org/apache/jmeter/save/TestElementUpgrader.kt @@ -0,0 +1,55 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.save + +import org.apache.jmeter.testelement.TestElement +import org.apache.jorphan.reflect.JMeterService +import org.apiguardian.api.API + +/** + * Upgrades a [TestElement] loaded from an older test-plan format, transforming legacy properties + * into their current representation. + * + * Unlike [org.apache.jmeter.util.NameUpdater], which only renames property keys and element + * classes, an upgrader can transform property *values* — for example, a boolean flag into an enum + * resource key. + * + * Implementations are discovered with [java.util.ServiceLoader], so register them with + * `@AutoService(TestElementUpgrader::class)`. + * + * Implementations must be **idempotent** and **forward-only**: + * - running [upgrade] on an already-current element returns `false` and changes nothing; + * - an upgrade never re-creates the legacy shape it just removed. + * + * These rules let [TestElementUpgraders] run every upgrader to a fix point, so chained upgrades + * (one property migrated, then migrated again later) converge regardless of registration order, + * without numbering the migrations. + * + * @since 6.0.0 + */ +@JMeterService +@API(status = API.Status.EXPERIMENTAL, since = "6.0.0") +public fun interface TestElementUpgrader { + /** + * Upgrades a single element in place. + * + * @param element the element to inspect and, if it carries a legacy shape, upgrade + * @return `true` if the element was modified, `false` otherwise + */ + public fun upgrade(element: TestElement): Boolean +} diff --git a/src/core/src/main/kotlin/org/apache/jmeter/save/TestElementUpgraders.kt b/src/core/src/main/kotlin/org/apache/jmeter/save/TestElementUpgraders.kt new file mode 100644 index 00000000000..c6d2a7d3447 --- /dev/null +++ b/src/core/src/main/kotlin/org/apache/jmeter/save/TestElementUpgraders.kt @@ -0,0 +1,125 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.save + +import org.apache.jmeter.testelement.TestElement +import org.apache.jmeter.util.JMeterUtils +import org.apache.jorphan.collections.HashTree +import org.apache.jorphan.reflect.LogAndIgnoreServiceLoadExceptionHandler +import org.apiguardian.api.API +import org.slf4j.LoggerFactory +import java.util.ServiceLoader + +/** + * Discovers [TestElementUpgrader] services and applies them to a loaded test plan. + * + * Each element is upgraded to a fix point: every upgrader is applied to the element repeatedly + * until a pass changes nothing. Because each upgrader is idempotent, forward-only, and touches only + * the element it is given, a chain of upgrades on the same property converges without any ordering + * metadata, and elements can be upgraded independently of each other. + * + * @since 6.0.0 + */ +@API(status = API.Status.EXPERIMENTAL, since = "6.0.0") +public object TestElementUpgraders { + private val log = LoggerFactory.getLogger(TestElementUpgraders::class.java) + + private val upgraders: List = load() + + private fun load(): List = + try { + JMeterUtils.loadServicesAndScanJars( + TestElementUpgrader::class.java, + ServiceLoader.load(TestElementUpgrader::class.java), + Thread.currentThread().contextClassLoader, + LogAndIgnoreServiceLoadExceptionHandler(log) + ).toList() + } catch (e: Exception) { + log.error("Unable to load TestElementUpgrader services", e) + emptyList() + } + + /** + * Applies every registered upgrader to every [TestElement] in the tree. + * + * @param tree the loaded test plan to upgrade in place + */ + @JvmStatic + public fun upgrade(tree: HashTree) { + if (upgraders.isEmpty()) { + return + } + // Walk the tree iteratively (no recursion) and upgrade each element as it is visited. Property + // upgrades change only an element's own properties, not the tree structure, and HashTree keys + // by identity (IdentityHashMap), so editing an element in place during the walk is safe and + // needs no separate "collect to a list" pass. A future structural phase would run before this + // one, so the tree shape is already stable here. + val pending = ArrayDeque() + pending.addLast(tree) + while (pending.isNotEmpty()) { + val subTree = pending.removeLast() + for (node in subTree.list()) { + if (node is TestElement) { + upgrade(node) + } + pending.addLast(subTree.getTree(node)) + } + } + } + + private fun upgrade(element: TestElement) { + // A forward-only chain on one property settles within `upgraders.size` passes; the extra pass + // confirms convergence, and the bound stops a misbehaving (cyclic) upgrader from looping forever. + val maxPasses = upgraders.size + 1 + var pass = 0 + var changed = true + while (changed && pass < maxPasses) { + changed = false + for (upgrader in upgraders) { + changed = applyUpgrader(upgrader, element) || changed + } + pass++ + } + if (changed) { + log.warn( + "TestElement upgraders did not converge for {} after {} passes; some legacy properties may remain", + describe(element), maxPasses + ) + } + } + + private fun applyUpgrader(upgrader: TestElementUpgrader, element: TestElement): Boolean = + try { + val changed = upgrader.upgrade(element) + if (changed && log.isDebugEnabled) { + log.debug("{} upgraded {}", upgrader.javaClass.name, describe(element)) + } + changed + } catch (e: Exception) { // NOSONAR one bad upgrader must not make the whole plan unloadable + // Surfaced at ERROR so it is visible without enabling debug logging: name the element the + // user can recognise, say what happened, and that the element was left as-is. + log.error( + "Failed to upgrade {} from an older JMeter format; leaving it unchanged. Failing upgrader: {}", + describe(element), upgrader.javaClass.name, e + ) + false + } + + private fun describe(element: TestElement): String = + "element '${element.name}' (${element.javaClass.name})" +} diff --git a/src/protocol/http/src/main/kotlin/org/apache/jmeter/protocol/http/sampler/ResponseProcessingModeUpgrader.kt b/src/protocol/http/src/main/kotlin/org/apache/jmeter/protocol/http/sampler/ResponseProcessingModeUpgrader.kt new file mode 100644 index 00000000000..7af017de51b --- /dev/null +++ b/src/protocol/http/src/main/kotlin/org/apache/jmeter/protocol/http/sampler/ResponseProcessingModeUpgrader.kt @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.protocol.http.sampler + +import com.google.auto.service.AutoService +import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase.ResponseProcessingMode +import org.apache.jmeter.save.TestElementUpgrader +import org.apache.jmeter.testelement.TestElement +import org.apiguardian.api.API + +/** + * Upgrades the legacy `HTTPSampler.md5` boolean property to `HTTPSampler.responseProcessingMode`. + * + * It keys off the property name, not the element class, so it upgrades both HTTP samplers and HTTP + * Request Defaults (a `ConfigTestElement`). `md5=true` becomes the decoded-MD5 checksum mode (the + * historical "Save as MD5" behaviour); `md5=false` becomes "store the response", which keeps it as + * an explicit choice that still overrides a value inherited from HTTP Request Defaults. + * + * @since 6.0.0 + */ +@AutoService(TestElementUpgrader::class) +@API(status = API.Status.INTERNAL, since = "6.0.0") +public class ResponseProcessingModeUpgrader : TestElementUpgrader { + override fun upgrade(element: TestElement): Boolean { + val schema = HTTPSamplerBaseSchema.INSTANCE + + @Suppress("DEPRECATION") + val md5 = element.getPropertyOrNull(schema.storeAsMD5)?.booleanValue ?: return false + // Keep an already-set mode (idempotent, and a newer property wins over the legacy flag). + if (element.getPropertyOrNull(schema.responseProcessingMode.name) == null) { + val mode = if (md5) { + ResponseProcessingMode.CHECKSUM_DECODED_MD5 + } else { + ResponseProcessingMode.STORE_COMPRESSED + } + element[schema.responseProcessingMode] = mode.resourceKey + } + @Suppress("DEPRECATION") + element.removeProperty(schema.storeAsMD5) + return true + } +} diff --git a/src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/sampler/ResponseProcessingModeUpgradeOnLoadTest.kt b/src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/sampler/ResponseProcessingModeUpgradeOnLoadTest.kt new file mode 100644 index 00000000000..dd7c546a6a9 --- /dev/null +++ b/src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/sampler/ResponseProcessingModeUpgradeOnLoadTest.kt @@ -0,0 +1,55 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.protocol.http.sampler + +import org.apache.jmeter.config.ConfigTestElement +import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase.ResponseProcessingMode +import org.apache.jmeter.save.TestElementUpgraders +import org.apache.jmeter.testelement.property.BooleanProperty +import org.apache.jorphan.collections.ListedHashTree +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** + * End-to-end check of the [TestElementUpgraders] driver with the ServiceLoader-registered + * [ResponseProcessingModeUpgrader], mirroring what SaveService runs after loading a test plan. + */ +class ResponseProcessingModeUpgradeOnLoadTest { + private val md5 = "HTTPSampler.md5" + private val mode = HTTPSamplerBaseSchema.INSTANCE.responseProcessingMode.name + + @Test + fun upgradesBothSamplerAndHttpRequestDefaults() { + val sampler = HTTPSamplerProxy().apply { setProperty(BooleanProperty(md5, true)) } + // HTTP Request Defaults are a ConfigTestElement; the upgrader keys off the property name, + // so it must be upgraded too. + val defaults = ConfigTestElement().apply { setProperty(BooleanProperty(md5, false)) } + + val tree = ListedHashTree() + tree.add(sampler) + tree.add(defaults) + + TestElementUpgraders.upgrade(tree) + + assertEquals(ResponseProcessingMode.CHECKSUM_DECODED_MD5.resourceKey, sampler.getPropertyAsString(mode)) + assertEquals("", sampler.getPropertyAsString(md5), "the sampler's legacy md5 must be removed") + + assertEquals(ResponseProcessingMode.STORE_COMPRESSED.resourceKey, defaults.getPropertyAsString(mode)) + assertEquals("", defaults.getPropertyAsString(md5), "the defaults' legacy md5 must be removed") + } +} diff --git a/src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/sampler/ResponseProcessingModeUpgraderTest.kt b/src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/sampler/ResponseProcessingModeUpgraderTest.kt new file mode 100644 index 00000000000..2895e767989 --- /dev/null +++ b/src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/sampler/ResponseProcessingModeUpgraderTest.kt @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.protocol.http.sampler + +import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase.ResponseProcessingMode +import org.apache.jmeter.testelement.property.BooleanProperty +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ResponseProcessingModeUpgraderTest { + private val upgrader = ResponseProcessingModeUpgrader() + private val md5 = "HTTPSampler.md5" + private val mode = HTTPSamplerBaseSchema.INSTANCE.responseProcessingMode.name + + @Test + fun md5TrueBecomesChecksumDecodedMd5() { + val element = HTTPSamplerProxy() + element.setProperty(BooleanProperty(md5, true)) + + assertTrue(upgrader.upgrade(element)) + assertEquals(ResponseProcessingMode.CHECKSUM_DECODED_MD5.resourceKey, element.getPropertyAsString(mode)) + assertEquals("", element.getPropertyAsString(md5), "the legacy md5 property must be removed") + assertFalse(upgrader.upgrade(element), "a second run is a no-op (idempotent)") + } + + @Test + fun md5FalseBecomesStoreCompressed() { + val element = HTTPSamplerProxy() + element.setProperty(BooleanProperty(md5, false)) + + assertTrue(upgrader.upgrade(element)) + assertEquals(ResponseProcessingMode.STORE_COMPRESSED.resourceKey, element.getPropertyAsString(mode)) + assertEquals("", element.getPropertyAsString(md5)) + } + + @Test + fun elementWithoutMd5IsUntouched() { + val element = HTTPSamplerProxy() + assertFalse(upgrader.upgrade(element)) + assertEquals("", element.getPropertyAsString(mode)) + } + + @Test + fun existingModeIsKeptAndLegacyMd5Removed() { + val element = HTTPSamplerProxy() + element.setProperty(mode, ResponseProcessingMode.FETCH_AND_DISCARD.resourceKey) + element.setProperty(BooleanProperty(md5, true)) + + assertTrue(upgrader.upgrade(element)) + assertEquals( + ResponseProcessingMode.FETCH_AND_DISCARD.resourceKey, + element.getPropertyAsString(mode), + "an explicit mode must win over the legacy md5" + ) + assertEquals("", element.getPropertyAsString(md5)) + } +}