threadLocalInflater =
+ ThreadLocal.withInitial(() -> new Inflater(true));
+
static {
if (START_TIMESTAMP) {
log.info("Note: Sample TimeStamps are START times");
@@ -161,6 +174,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
@@ -218,7 +233,7 @@ public class SampleResult implements Serializable, Cloneable, Searchable {
// TODO do contentType and/or dataEncoding belong in HTTPSampleResult instead?
private String dataEncoding;// (is this really the character set?) e.g.
- // ISO-8895-1, UTF-8
+ // ISO-8895-1, UTF-8
private String contentType = ""; // e.g. text/html; charset=utf-8
@@ -277,6 +292,11 @@ public class SampleResult implements Serializable, Cloneable, Searchable {
*/
private transient volatile String responseDataAsString;
+ private static final String GZIP_ENCODING = "gzip";
+ private static final String X_GZIP_ENCODING = "x-gzip";
+ private static final String DEFLATE_ENCODING = "deflate";
+ private static final String BROTLI_ENCODING = "br";
+
public SampleResult() {
this(USE_NANO_TIME, NANOTHREAD_SLEEP);
}
@@ -792,6 +812,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 +833,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 +1696,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/java/org/apache/jmeter/testbeans/gui/EnumEditor.java b/src/core/src/main/java/org/apache/jmeter/testbeans/gui/EnumEditor.java
index 7c241aa4255..a77ac353f1d 100644
--- a/src/core/src/main/java/org/apache/jmeter/testbeans/gui/EnumEditor.java
+++ b/src/core/src/main/java/org/apache/jmeter/testbeans/gui/EnumEditor.java
@@ -31,6 +31,7 @@
import javax.swing.JList;
import org.apache.jmeter.gui.ClearGui;
+import org.apache.jorphan.locale.ResourceKeyed;
import org.apache.jorphan.util.EnumUtils;
/**
@@ -41,14 +42,13 @@
* The provided GUI is a combo box with an option for each value in the enum.
*
*/
-class EnumEditor extends PropertyEditorSupport implements ClearGui {
+class EnumEditor & ResourceKeyed> extends PropertyEditorSupport implements ClearGui {
+ private final JComboBox combo;
- private final JComboBox> combo;
+ private final T defaultValue;
- private final Enum> defaultValue;
-
- public EnumEditor(final PropertyDescriptor descriptor, final Class extends Enum>> enumClazz, final ResourceBundle rb) {
- DefaultComboBoxModel> model = new DefaultComboBoxModel<>();
+ public EnumEditor(final PropertyDescriptor descriptor, final Class enumClass, final ResourceBundle rb) {
+ DefaultComboBoxModel model = new DefaultComboBoxModel<>();
combo = new JComboBox<>(model);
combo.setEditable(false);
combo.setRenderer(
@@ -56,19 +56,18 @@ public EnumEditor(final PropertyDescriptor descriptor, final Class extends Enu
@Override
public Component getListCellRendererComponent(JList> list, Object value, int index, boolean isSelected, boolean cellHasFocus) {
JLabel label = (JLabel) super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus);
- Enum> enumValue = (Enum>) value;
- label.setText(rb.getString(EnumUtils.getStringValue(enumValue)));
+ label.setText(rb.getString(enumClass.cast(value).getResourceKey()));
return label;
}
}
);
- List extends Enum>> values = EnumUtils.values(enumClazz);
- for(Enum> e : values) {
+ List values = EnumUtils.getEnumValues(enumClass);
+ for (T e : values) {
model.addElement(e);
}
Object def = descriptor.getValue(GenericTestBeanCustomizer.DEFAULT);
if (def instanceof Enum> enumValue) {
- defaultValue = enumValue;
+ defaultValue = enumClass.cast(enumValue);
} else if (def instanceof Integer index) {
defaultValue = values.get(index);
} else {
@@ -99,25 +98,24 @@ public void setValue(Object value) {
} else if (value instanceof Integer integer) {
combo.setSelectedIndex(integer);
} else if (value instanceof String string) {
- ComboBoxModel> model = combo.getModel();
- for (int i = 0; i < model.getSize(); i++) {
- Enum> element = model.getElementAt(i);
- if (EnumUtils.getStringValue(element).equals(string)) {
- combo.setSelectedItem(element);
- return;
- }
- }
+ setAsText(string);
}
}
@Override
public void setAsText(String value) {
- throw new UnsupportedOperationException("Not supported yet. Use enum value rather than text, got " + value);
+ ComboBoxModel model = combo.getModel();
+ for (int i = 0; i < model.getSize(); i++) {
+ T element = model.getElementAt(i);
+ if (value.equals(element.getResourceKey())) {
+ combo.setSelectedItem(element);
+ return;
+ }
+ }
}
@Override
public void clearGui() {
combo.setSelectedItem(defaultValue);
}
-
}
diff --git a/src/core/src/main/java/org/apache/jmeter/testbeans/gui/GenericTestBeanCustomizer.java b/src/core/src/main/java/org/apache/jmeter/testbeans/gui/GenericTestBeanCustomizer.java
index 9f25190d335..b53bfca541b 100644
--- a/src/core/src/main/java/org/apache/jmeter/testbeans/gui/GenericTestBeanCustomizer.java
+++ b/src/core/src/main/java/org/apache/jmeter/testbeans/gui/GenericTestBeanCustomizer.java
@@ -43,7 +43,6 @@
import javax.swing.JScrollPane;
import javax.swing.SwingConstants;
-import org.apache.jmeter.JMeter;
import org.apache.jmeter.gui.ClearGui;
import org.apache.jmeter.testbeans.TestBeanHelper;
import org.apache.jmeter.testelement.property.IntegerProperty;
@@ -51,6 +50,7 @@
import org.apache.jmeter.testelement.property.LongProperty;
import org.apache.jmeter.testelement.property.StringProperty;
import org.apache.jmeter.util.JMeterUtils;
+import org.apache.jorphan.locale.ResourceKeyed;
import org.apache.jorphan.util.EnumUtils;
import org.apiguardian.api.API;
import org.jspecify.annotations.Nullable;
@@ -338,9 +338,9 @@ public GenericTestBeanCustomizer(){
* @return a StringProperty containing the normalized enum value, or null if the property is invalid or unrecognized
*/
@API(status = API.Status.INTERNAL, since = "6.0.0")
- public static > @Nullable JMeterProperty normalizeEnumProperty(
+ public static & ResourceKeyed> @Nullable JMeterProperty normalizeEnumProperty(
Class> klass, Class enumKlass, JMeterProperty property) {
- List values = EnumUtils.values(enumKlass);
+ List values = EnumUtils.getEnumValues(enumKlass);
T value;
if (property instanceof IntegerProperty intProperty) {
int index = intProperty.getIntValue();
@@ -362,26 +362,26 @@ public GenericTestBeanCustomizer(){
return null;
}
value = normalizeEnumStringValue(stringValue, klass, enumKlass);
- if (stringValue.equals(EnumUtils.getStringValue(value))) {
+ if (stringValue.equals(value.getResourceKey())) {
// If the input property was good enough, keep it
return property;
}
} else {
return null;
}
- return new StringProperty(property.getName(), EnumUtils.getStringValue(value));
+ return new StringProperty(property.getName(), value.getResourceKey());
}
@API(status = API.Status.INTERNAL, since = "6.0.0")
- public static > T normalizeEnumStringValue(String value, Class> klass, Class enumKlass) {
+ public static & ResourceKeyed> T normalizeEnumStringValue(String value, Class> klass, Class enumKlass) {
T enumValue = EnumUtils.valueOf(enumKlass, value);
if (enumValue != null) {
return enumValue;
}
- return normalizeEnumStringValue(value, klass, EnumUtils.values(enumKlass));
+ return normalizeEnumStringValue(value, klass, EnumUtils.getEnumValues(enumKlass));
}
- private static > T normalizeEnumStringValue(String value, Class> klass, List values) {
+ private static & ResourceKeyed> T normalizeEnumStringValue(String value, Class> klass, List values) {
// Fallback: the value might be localized, thus check the current and root locales
String bundleName = null;
try {
@@ -408,9 +408,9 @@ private static > T normalizeEnumStringValue(String value, Clas
return findEnumValue(value, rootBundle, values);
}
- private static > @Nullable T findEnumValue(String stringValue, ResourceBundle rb, List values) {
+ private static & ResourceKeyed> @Nullable T findEnumValue(String stringValue, ResourceBundle rb, List values) {
for (T enumValue : values) {
- if (stringValue.equals(rb.getObject(enumValue.toString()))) {
+ if (stringValue.equals(rb.getObject(enumValue.getResourceKey()))) {
log.debug("Converted {} to {} using Locale: {}", stringValue, enumValue, rb.getLocale());
return enumValue;
}
diff --git a/src/core/src/main/java/org/apache/jmeter/testelement/AbstractTestElement.java b/src/core/src/main/java/org/apache/jmeter/testelement/AbstractTestElement.java
index 71823d6a24d..cd12f1ab088 100644
--- a/src/core/src/main/java/org/apache/jmeter/testelement/AbstractTestElement.java
+++ b/src/core/src/main/java/org/apache/jmeter/testelement/AbstractTestElement.java
@@ -29,10 +29,12 @@
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantReadWriteLock;
+import org.apache.jmeter.engine.util.LightweightClone;
import org.apache.jmeter.engine.util.NoThreadClone;
import org.apache.jmeter.gui.Searchable;
import org.apache.jmeter.testelement.property.BooleanProperty;
import org.apache.jmeter.testelement.property.CollectionProperty;
+import org.apache.jmeter.testelement.property.FunctionProperty;
import org.apache.jmeter.testelement.property.IntegerProperty;
import org.apache.jmeter.testelement.property.JMeterProperty;
import org.apache.jmeter.testelement.property.LongProperty;
@@ -142,6 +144,12 @@ static ResourceLock releaseWrite(ReentrantReadWriteLock lock) {
private transient boolean runningVersion = false;
+ /**
+ * Indicates whether this element's properties are shared with another element.
+ * When true, the propMap is shared and should be treated as read-only.
+ */
+ private transient boolean propertiesShared = false;
+
// Thread-specific variables saved here to save recalculation
private transient JMeterContext threadContext = null;
@@ -172,6 +180,179 @@ public Object clone() {
}
}
+ /**
+ * Creates a lightweight clone that shares properties with this element.
+ * This is more memory-efficient than a full clone for elements that implement
+ * {@link LightweightClone} and don't modify properties during test execution.
+ *
+ * The lightweight clone:
+ *
+ * - Creates a new instance with default constructor
+ * - Shares the property map with the original element (no deep copy)
+ * - Has fresh transient state (threadContext, threadName, etc.)
+ *
+ *
+ * @return a lightweight clone of this element
+ * @throws LinkageError if cloning fails due to reflection errors
+ */
+ public Object lightweightClone() {
+ try {
+ AbstractTestElement clone = this.getClass().getDeclaredConstructor().newInstance();
+ // Clear default properties set by constructor
+ clone.clear();
+ // Share property map reference with this element
+ clone.sharePropertiesFrom(this);
+ clone.setRunningVersion(isRunningVersion());
+ return clone;
+ } catch (ReflectiveOperationException e) {
+ throw new LinkageError(e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Shares the property map from the source element.
+ *
+ * This method is called only for elements that have NO variable properties
+ * (verified by {@link #hasVariableProperties()} in TreeCloner before calling
+ * {@link #lightweightClone()}). Therefore, it's safe to share property references
+ * directly without cloning - this provides significant memory savings.
+ *
+ *
+ * TestElementProperty is still cloned because nested TestElements may have
+ * transient runtime state that needs to be per-thread.
+ *
+ *
+ * @param source the element to share properties from
+ */
+ void sharePropertiesFrom(AbstractTestElement source) {
+ try (ResourceLock ignored = writeLock()) {
+ // Share property references directly for maximum memory efficiency
+ // This is safe because TreeCloner only calls lightweightClone() for
+ // elements that have no variable properties (hasVariableProperties() == false)
+ for (Map.Entry entry : source.propMap.entrySet()) {
+ JMeterProperty prop = entry.getValue();
+ // Only clone TestElementProperty because nested TestElements
+ // may have transient state that needs to be per-thread
+ if (prop instanceof TestElementProperty) {
+ prop = prop.clone();
+ }
+ // All other properties are shared by reference - same String objects,
+ // same property instances across all threads
+ this.propMap.put(entry.getKey(), prop);
+ }
+ Map propMapConcurrent = this.propMapConcurrent;
+ if (propMapConcurrent != null) {
+ propMapConcurrent.putAll(this.propMap);
+ }
+ }
+ // Mark as shared to skip recoverRunningVersion and enable copy-on-write
+ this.propertiesShared = true;
+ }
+
+ /**
+ * Returns whether this element's properties are shared with another element.
+ *
+ * @return true if properties are shared (element was created via lightweightClone)
+ */
+ public boolean isPropertiesShared() {
+ return propertiesShared;
+ }
+
+ /**
+ * Checks if this element has any properties containing JMeter variables or functions.
+ * Elements with variable properties should not use lightweight cloning because
+ * the variable evaluation needs per-thread state.
+ *
+ * @return true if any property contains variables (${...}) or functions (__())
+ */
+ public boolean hasVariableProperties() {
+ try (ResourceLock ignored = readLock()) {
+ for (JMeterProperty prop : propMap.values()) {
+ if (propertyHasVariables(prop)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Checks if a single property contains variables or functions.
+ */
+ private static boolean propertyHasVariables(JMeterProperty prop) {
+ // FunctionProperty is created when StringProperty with ${...} is transformed
+ if (prop instanceof FunctionProperty) {
+ return true;
+ }
+ // Check StringProperty for variable patterns (before transformation)
+ if (prop instanceof StringProperty stringProperty) {
+ String value = stringProperty.getStringValue();
+ if (value != null && (value.contains("${") || value.contains("__("))) {
+ return true;
+ }
+ }
+ // Check nested properties in collections
+ if (prop instanceof CollectionProperty collectionProperty) {
+ for (JMeterProperty nested : collectionProperty) {
+ if (propertyHasVariables(nested)) {
+ return true;
+ }
+ }
+ }
+ // Check nested properties in maps
+ if (prop instanceof MapProperty mapProperty) {
+ PropertyIterator iter = mapProperty.valueIterator();
+ while (iter.hasNext()) {
+ if (propertyHasVariables(iter.next())) {
+ return true;
+ }
+ }
+ }
+ // Check nested TestElement
+ if (prop instanceof TestElementProperty testElementProperty) {
+ TestElement nested = testElementProperty.getElement();
+ if (nested instanceof AbstractTestElement abstractNested) {
+ if (abstractNested.hasVariableProperties()) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Ensures this element has its own copy of properties (copy-on-write).
+ * If properties are currently shared with another element, this method
+ * creates deep clones of all properties before any modification can occur.
+ *
+ * This allows lightweight clones to share properties for memory efficiency,
+ * while still supporting runtime modifications when needed (e.g., when a
+ * Header Manager has a variable like ${updateme} that gets modified).
+ */
+ protected void ensureOwnProperties() {
+ if (!propertiesShared) {
+ return;
+ }
+ try (ResourceLock ignored = writeLock()) {
+ // Double-check after acquiring lock
+ if (!propertiesShared) {
+ return;
+ }
+ // Create deep copies of all properties
+ Map newPropMap = new LinkedHashMap<>();
+ for (Map.Entry entry : propMap.entrySet()) {
+ newPropMap.put(entry.getKey(), entry.getValue().clone());
+ }
+ propMap.clear();
+ propMap.putAll(newPropMap);
+ if (propMapConcurrent != null) {
+ propMapConcurrent.clear();
+ propMapConcurrent.putAll(propMap);
+ }
+ propertiesShared = false;
+ }
+ }
+
/**
* {@inheritDoc}
*/
@@ -221,6 +402,10 @@ public void clearTestElementChildren(){
*/
@Override
public void removeProperty(String key) {
+ // Copy-on-write: ensure we have our own properties before modifying
+ if (isRunningVersion()) {
+ ensureOwnProperties();
+ }
try (ResourceLock ignored = writeLock()) {
propMap.remove(key);
Map propMapConcurrent = this.propMapConcurrent;
@@ -425,6 +610,8 @@ protected void addProperty(JMeterProperty property, boolean clone) {
propertyToPut = property.clone();
}
if (isRunningVersion()) {
+ // Copy-on-write: ensure we have our own properties before modifying
+ ensureOwnProperties();
setTemporary(propertyToPut);
} else {
clearTemporary(property);
@@ -483,6 +670,8 @@ protected void logProperties() {
@Override
public void setProperty(JMeterProperty property) {
if (isRunningVersion()) {
+ // Copy-on-write: ensure we have our own properties before modifying
+ ensureOwnProperties();
if (getProperty(property.getName()) instanceof NullProperty) {
addProperty(property);
} else {
@@ -649,6 +838,9 @@ public boolean isRunningVersion() {
public void setRunningVersion(boolean runningVersion) {
try (ResourceLock ignored = writeLock()) {
this.runningVersion = runningVersion;
+ // Note: Even for shared properties (lightweight clones), we must call
+ // property.setRunningVersion() to ensure variable substitution works.
+ // The property transformation is idempotent and thread-safe.
PropertyIterator iter = propertyIterator();
Map propMapConcurrent = this.propMapConcurrent;
while (iter.hasNext()) {
@@ -671,6 +863,11 @@ public void recoverRunningVersion() {
// See https://github.com/apache/jmeter/issues/5875
return;
}
+ if (propertiesShared) {
+ // Properties are shared with other elements (lightweight clone),
+ // so there's nothing to recover - the properties are read-only
+ return;
+ }
try (ResourceLock ignored = writeLock()) {
Iterator> iter = propMap.entrySet().iterator();
while (iter.hasNext()) {
diff --git a/src/core/src/main/java/org/apache/jmeter/threads/JMeterThread.java b/src/core/src/main/java/org/apache/jmeter/threads/JMeterThread.java
index 35f94b26982..c6498ba8cd5 100644
--- a/src/core/src/main/java/org/apache/jmeter/threads/JMeterThread.java
+++ b/src/core/src/main/java/org/apache/jmeter/threads/JMeterThread.java
@@ -80,6 +80,10 @@ public class JMeterThread implements Runnable, Interruptible {
private static final int RAMPUP_GRANULARITY =
JMeterUtils.getPropDefault("jmeterthread.rampup.granularity", 1000); // $NON-NLS-1$
+ /** How often to check for shutdown during timer delay, default 1000ms */
+ private static final int TIMER_GRANULARITY =
+ JMeterUtils.getPropDefault("jmeterthread.timer.granularity", 1000); // $NON-NLS-1$
+
private static final float TIMER_FACTOR = JMeterUtils.getPropDefault("timer.factor", 1.0f);
private static final TimerService TIMER_SERVICE = TimerService.getInstance();
@@ -998,21 +1002,36 @@ private void delay(List extends Timer> timers) {
totalDelay += delay;
}
if (totalDelay > 0) {
- try {
- if (scheduler) {
- // We reduce pause to ensure end of test is not delayed by a sleep ending after test scheduled end
- // See Bug 60049
- totalDelay = TIMER_SERVICE.adjustDelay(totalDelay, endTime, false);
- if (totalDelay < 0) {
- log.debug("The delay would be longer than the scheduled period, so stop thread now.");
- running = false;
- return;
+ if (scheduler) {
+ // We reduce pause to ensure end of test is not delayed by a sleep ending after test scheduled end
+ // See Bug 60049
+ totalDelay = TIMER_SERVICE.adjustDelay(totalDelay, endTime, false);
+ if (totalDelay < 0) {
+ log.debug("The delay would be longer than the scheduled period, so stop thread now.");
+ running = false;
+ return;
+ }
+ }
+ // Use granular sleeps to allow quick response to shutdown
+ long start = System.currentTimeMillis();
+ long end = start + totalDelay;
+ long now;
+ long pause = TIMER_GRANULARITY;
+ while (running && (now = System.currentTimeMillis()) < end) {
+ long togo = end - now;
+ if (togo < pause) {
+ pause = togo;
+ }
+ try {
+ TimeUnit.MILLISECONDS.sleep(pause);
+ } catch (InterruptedException e) {
+ if (running) { // NOSONAR running may have been changed from another thread
+ log.warn("The delay timer was interrupted - Loss of delay for {} was {}ms out of {}ms",
+ threadName, System.currentTimeMillis() - start, totalDelay);
}
+ Thread.currentThread().interrupt();
+ break;
}
- TimeUnit.MILLISECONDS.sleep(totalDelay);
- } catch (InterruptedException e) {
- log.warn("The delay timer was interrupted - probably did not wait as long as intended.");
- Thread.currentThread().interrupt();
}
}
}
diff --git a/src/core/src/main/java/org/apache/jmeter/threads/TestCompiler.java b/src/core/src/main/java/org/apache/jmeter/threads/TestCompiler.java
index 14b537a0e19..9e10fbbf480 100644
--- a/src/core/src/main/java/org/apache/jmeter/threads/TestCompiler.java
+++ b/src/core/src/main/java/org/apache/jmeter/threads/TestCompiler.java
@@ -115,10 +115,20 @@ public SamplePackage configureTransactionSampler(TransactionSampler transactionS
}
/**
- * Reset pack to its initial state
+ * Reset pack to its initial state and clean up transaction results if needed
* @param pack the {@link SamplePackage} to reset
*/
public void done(SamplePackage pack) {
+ Sampler sampler = pack.getSampler();
+ if (sampler instanceof TransactionSampler transactionSampler) {
+ TransactionController controller = transactionSampler.getTransactionController();
+ if (transactionSampler.isTransactionDone()) {
+ // Create new sampler for next iteration
+ TransactionSampler newSampler = new TransactionSampler(controller, transactionSampler.getName());
+ SamplePackage newPack = transactionControllerConfigMap.get(controller);
+ newPack.setSampler(newSampler);
+ }
+ }
pack.recoverRunningVersion();
}
diff --git a/src/core/src/main/java/org/apache/jmeter/threads/ThreadGroup.java b/src/core/src/main/java/org/apache/jmeter/threads/ThreadGroup.java
index 9599fe0e1c4..de2bf55263b 100644
--- a/src/core/src/main/java/org/apache/jmeter/threads/ThreadGroup.java
+++ b/src/core/src/main/java/org/apache/jmeter/threads/ThreadGroup.java
@@ -54,6 +54,48 @@ public class ThreadGroup extends AbstractThreadGroup {
private static final int RAMPUP_GRANULARITY =
JMeterUtils.getPropDefault("jmeterthread.rampup.granularity", 1000); // $NON-NLS-1$
+ /** Whether to use Java 21 Virtual Threads for JMeter threads */
+ private static final boolean VIRTUAL_THREADS_ENABLED =
+ JMeterUtils.getPropDefault("jmeter.threads.virtual.enabled", true); // $NON-NLS-1$
+
+ /** Cached reference to Thread.ofVirtual() method for Java 21+ support, null if not available */
+ private static final java.lang.invoke.MethodHandle VIRTUAL_THREAD_BUILDER;
+
+ /** Cached reference to Thread.Builder.OfVirtual.name() method */
+ private static final java.lang.invoke.MethodHandle VIRTUAL_BUILDER_NAME;
+
+ /** Cached reference to Thread.Builder.OfVirtual.unstarted() method */
+ private static final java.lang.invoke.MethodHandle VIRTUAL_BUILDER_UNSTARTED;
+
+ static {
+ java.lang.invoke.MethodHandle ofVirtual = null;
+ java.lang.invoke.MethodHandle builderName = null;
+ java.lang.invoke.MethodHandle builderUnstarted = null;
+ if (VIRTUAL_THREADS_ENABLED) {
+ try {
+ // Try to get Thread.ofVirtual() method (Java 21+)
+ java.lang.invoke.MethodHandles.Lookup lookup = java.lang.invoke.MethodHandles.lookup();
+ java.lang.reflect.Method ofVirtualMethod = Thread.class.getMethod("ofVirtual");
+ ofVirtual = lookup.unreflect(ofVirtualMethod);
+
+ // Get the builder class and its methods
+ Class> builderClass = ofVirtualMethod.getReturnType();
+ java.lang.reflect.Method nameMethod = builderClass.getMethod("name", String.class);
+ builderName = lookup.unreflect(nameMethod);
+
+ java.lang.reflect.Method unstartedMethod = builderClass.getMethod("unstarted", Runnable.class);
+ builderUnstarted = lookup.unreflect(unstartedMethod);
+
+ log.info("Virtual threads support enabled (Java 21+)");
+ } catch (NoSuchMethodException | IllegalAccessException e) {
+ log.warn("Virtual threads requested but not available (requires Java 21+), falling back to platform threads");
+ }
+ }
+ VIRTUAL_THREAD_BUILDER = ofVirtual;
+ VIRTUAL_BUILDER_NAME = builderName;
+ VIRTUAL_BUILDER_UNSTARTED = builderUnstarted;
+ }
+
//+ JMX entries - do not change the string values
/** Ramp-up time */
@@ -257,6 +299,29 @@ public void start(int groupNum, ListenerNotifier notifier, ListedHashTree thread
log.info("Started thread group number {}", groupNumber);
}
+ /**
+ * Creates a thread (virtual or platform) based on configuration.
+ * When {@code jmeter.threads.virtual.enabled} is true and running on Java 21+,
+ * creates a virtual thread. Otherwise creates a platform thread.
+ *
+ * @param runnable the runnable to execute
+ * @param name the thread name
+ * @return an unstarted Thread
+ */
+ private static Thread createThread(Runnable runnable, String name) {
+ if (VIRTUAL_THREAD_BUILDER != null) {
+ try {
+ // Thread.ofVirtual().name(name).unstarted(runnable)
+ Object builder = VIRTUAL_THREAD_BUILDER.invoke();
+ builder = VIRTUAL_BUILDER_NAME.invoke(builder, name);
+ return (Thread) VIRTUAL_BUILDER_UNSTARTED.invoke(builder, runnable);
+ } catch (Throwable t) {
+ log.warn("Failed to create virtual thread, falling back to platform thread", t);
+ }
+ }
+ return new Thread(runnable, name);
+ }
+
/**
* Start a new {@link JMeterThread} and registers it
* @param notifier {@link ListenerNotifier}
@@ -273,7 +338,7 @@ private JMeterThread startNewThread(ListenerNotifier notifier, ListedHashTree th
JMeterThread jmThread = makeThread(engine, this, notifier, groupNumber, threadNum, cloneTree(threadGroupTree), variables);
scheduleThread(jmThread, now); // set start and end time
jmThread.setInitialDelay(delay);
- Thread newThread = new Thread(jmThread, jmThread.getThreadName());
+ Thread newThread = createThread(jmThread, jmThread.getThreadName());
registerStartedThread(jmThread, newThread);
newThread.start();
return jmThread;
@@ -587,8 +652,10 @@ public void run() {
jmThread.setScheduled(true);
jmThread.setEndTime(endtime);
}
- Thread newThread = new Thread(jmThread, jmThread.getThreadName());
- newThread.setDaemon(false); // ThreadStarter is daemon, but we don't want sampler threads to be so too
+ Thread newThread = createThread(jmThread, jmThread.getThreadName());
+ if (VIRTUAL_THREAD_BUILDER == null) {
+ newThread.setDaemon(false); // ThreadStarter is daemon, but we don't want sampler threads to be so too
+ }
registerStartedThread(jmThread, newThread);
newThread.start();
}
diff --git a/src/core/src/main/java/org/apache/jmeter/threads/gui/ThreadGroupGui.java b/src/core/src/main/java/org/apache/jmeter/threads/gui/ThreadGroupGui.java
index 3ddcd1f96ee..3c096d0b0eb 100644
--- a/src/core/src/main/java/org/apache/jmeter/threads/gui/ThreadGroupGui.java
+++ b/src/core/src/main/java/org/apache/jmeter/threads/gui/ThreadGroupGui.java
@@ -65,7 +65,8 @@ public class ThreadGroupGui extends AbstractThreadGroupGui implements ItemListen
private final JBooleanPropertyEditor scheduler =
new JBooleanPropertyEditor(
ThreadGroupSchema.INSTANCE.getUseScheduler(),
- JMeterUtils.getResString("scheduler"));
+ "scheduler",
+ JMeterUtils::getResString);
private final JTextField duration = new JTextField();
private final JLabel durationLabel = labelFor(duration, "duration");
@@ -76,7 +77,8 @@ public class ThreadGroupGui extends AbstractThreadGroupGui implements ItemListen
private final JBooleanPropertyEditor sameUserBox =
new JBooleanPropertyEditor(
AbstractThreadGroupSchema.INSTANCE.getSameUserOnNextIteration(),
- JMeterUtils.getResString("threadgroup_same_user"));
+ "threadgroup_same_user",
+ JMeterUtils::getResString);
public ThreadGroupGui() {
this(true);
@@ -199,7 +201,8 @@ private void init() { // WARNING: called from ctor so must not be overridden (i.
if (showDelayedStart) {
delayedStart = new JBooleanPropertyEditor(
ThreadGroupSchema.INSTANCE.getDelayedStart(),
- JMeterUtils.getResString("delayed_start")); // $NON-NLS-1$
+ "delayed_start",
+ JMeterUtils::getResString); // $NON-NLS-1$
threadPropsPanel.add(delayedStart, "span 2");
}
scheduler.addPropertyChangeListener(
diff --git a/src/core/src/main/kotlin/org/apache/jmeter/gui/JBooleanPropertyEditor.kt b/src/core/src/main/kotlin/org/apache/jmeter/gui/JBooleanPropertyEditor.kt
index 1b4e70af789..a410eb700d1 100644
--- a/src/core/src/main/kotlin/org/apache/jmeter/gui/JBooleanPropertyEditor.kt
+++ b/src/core/src/main/kotlin/org/apache/jmeter/gui/JBooleanPropertyEditor.kt
@@ -20,9 +20,12 @@ package org.apache.jmeter.gui
import org.apache.jmeter.testelement.TestElement
import org.apache.jmeter.testelement.property.BooleanProperty
import org.apache.jmeter.testelement.schema.BooleanPropertyDescriptor
-import org.apache.jmeter.util.JMeterUtils
import org.apache.jorphan.gui.JEditableCheckBox
+import org.apache.jorphan.locale.LocalizedString
+import org.apache.jorphan.locale.PlainValue
+import org.apache.jorphan.locale.ResourceLocalizer
import org.apiguardian.api.API
+import org.jetbrains.annotations.NonNls
/**
* Provides editor component for boolean properties that accommodate both true/false and expression string.
@@ -31,19 +34,21 @@ import org.apiguardian.api.API
@API(status = API.Status.EXPERIMENTAL, since = "5.6")
public class JBooleanPropertyEditor(
private val propertyDescriptor: BooleanPropertyDescriptor<*>,
- label: String,
-) : JEditableCheckBox(label, DEFAULT_CONFIGURATION), Binding {
+ label: @NonNls String,
+ resourceLocalizer: ResourceLocalizer,
+) : JEditableCheckBox(label, createConfiguration(resourceLocalizer), resourceLocalizer), Binding {
private companion object {
- @JvmField
- val DEFAULT_CONFIGURATION: Configuration = Configuration(
- startEditing = JMeterUtils.getResString("editable_checkbox.use_expression"),
- trueValue = "true",
- falseValue = "false",
- extraValues = listOf(
- "\${__P(property_name)}",
- "\${variable_name}",
+ private fun createConfiguration(resourceLocalizer: ResourceLocalizer) =
+ Configuration(
+ useExpression = LocalizedString("edit_as_expression_action", resourceLocalizer),
+ useExpressionTooltip = LocalizedString("edit_as_expression_tooltip", resourceLocalizer),
+ trueValue = LocalizedString("editable_checkbox.true", resourceLocalizer),
+ falseValue = LocalizedString("editable_checkbox.false", resourceLocalizer),
+ extraValues = listOf(
+ PlainValue("\${__P(property_name)}"),
+ PlainValue("\${variable_name}"),
+ )
)
- )
}
public fun reset() {
diff --git a/src/core/src/main/kotlin/org/apache/jmeter/gui/JEnumPropertyEditor.kt b/src/core/src/main/kotlin/org/apache/jmeter/gui/JEnumPropertyEditor.kt
new file mode 100644
index 00000000000..1bc31d068fd
--- /dev/null
+++ b/src/core/src/main/kotlin/org/apache/jmeter/gui/JEnumPropertyEditor.kt
@@ -0,0 +1,136 @@
+/*
+ * 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.gui
+
+import org.apache.jmeter.testelement.TestElement
+import org.apache.jmeter.testelement.property.StringProperty
+import org.apache.jmeter.testelement.schema.StringPropertyDescriptor
+import org.apache.jorphan.gui.JEditableComboBox
+import org.apache.jorphan.locale.LocalizedString
+import org.apache.jorphan.locale.LocalizedValue
+import org.apache.jorphan.locale.PlainValue
+import org.apache.jorphan.locale.ResourceKeyed
+import org.apache.jorphan.locale.ResourceLocalizer
+import org.apache.jorphan.util.enumValues
+import org.apiguardian.api.API
+import org.jetbrains.annotations.NonNls
+
+/**
+ * Property editor for enum values that implements [ResourceKeyed].
+ *
+ * This editor provides a combo box that displays localized enum values and can also
+ * accept custom expressions like `${__P(property)}` for dynamic configuration.
+ *
+ * The editor:
+ * - Displays predefined enum values with localized text
+ * - Stores enum resource keys (not enum names) in the test element property
+ * - Allows switching to an editable text field for expressions
+ * - Automatically detects and handles both enum values and custom expressions
+ *
+ * Example usage in a GUI class:
+ * ```java
+ * private final JEnumPropertyEditor modeEditor;
+ *
+ * modeEditor = new JEnumPropertyEditor<>(
+ * schema.getResponseProcessingMode(),
+ * "response_mode_label",
+ * ResponseProcessingMode.class,
+ * JMeterUtils::getResString
+ * );
+ * bindingGroup.add(modeEditor);
+ * ```
+ *
+ * @param E the enum type that implements [ResourceKeyed]
+ * @property propertyDescriptor the property descriptor for the enum property
+ * @property label the label text to display next to the combo box
+ * @property enumClass the class of the enum type
+ * @since 6.0.0
+ */
+@API(status = API.Status.EXPERIMENTAL, since = "6.0.0")
+public class JEnumPropertyEditor(
+ private val propertyDescriptor: StringPropertyDescriptor<*>,
+ label: @NonNls String,
+ enumClass: Class,
+ resourceLocalizer: ResourceLocalizer,
+) : JEditableComboBox(label, createConfiguration(enumClass, resourceLocalizer), resourceLocalizer), Binding
+ where E : Enum, E : ResourceKeyed {
+
+ private companion object {
+ @JvmStatic
+ private fun createConfiguration(
+ enumClass: Class,
+ resourceLocalizer: ResourceLocalizer
+ ): Configuration
+ where E : Enum, E : ResourceKeyed {
+ val resourceKeys = enumClass.enumValues.map {
+ LocalizedValue(it, resourceLocalizer)
+ }
+
+ return Configuration(
+ useExpression = LocalizedString("edit_as_expression_action", resourceLocalizer),
+ useExpressionTooltip = LocalizedString("edit_as_expression_tooltip", resourceLocalizer),
+ values = resourceKeys,
+ extraValues = listOf(
+ PlainValue("\${__P(property_name)}"),
+ PlainValue("\${variable_name}"),
+ )
+ )
+ }
+ }
+
+ /**
+ * Resets the editor to the default value specified in the property descriptor.
+ */
+ public fun reset() {
+ value = PlainValue(propertyDescriptor.defaultValue ?: "")
+ }
+
+ /**
+ * Updates the test element with the current value from the editor.
+ *
+ * The value is stored as-is (either a resource key or a custom expression).
+ */
+ override fun updateElement(testElement: TestElement) {
+ val currentValue = value
+ if ((currentValue as? PlainValue)?.value.isNullOrBlank() && propertyDescriptor.defaultValue == null) {
+ // Remove property if empty and no default
+ testElement.removeProperty(propertyDescriptor.name)
+ } else {
+ testElement[propertyDescriptor] = when (currentValue) {
+ is ResourceKeyed -> currentValue.resourceKey
+ else -> currentValue.toString()
+ }
+ }
+ }
+
+ /**
+ * Updates the editor UI from the test element's property value.
+ *
+ * Handles both enum resource keys and custom expression strings.
+ */
+ override fun updateUi(testElement: TestElement) {
+ val property = testElement.getPropertyOrNull(propertyDescriptor)
+ value = PlainValue(
+ when (property) {
+ is StringProperty -> property.stringValue
+ null -> propertyDescriptor.defaultValue ?: ""
+ else -> property.stringValue
+ }
+ )
+ }
+}
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/kotlin/org/apache/jmeter/threads/openmodel/OpenModelThreadGroup.kt b/src/core/src/main/kotlin/org/apache/jmeter/threads/openmodel/OpenModelThreadGroup.kt
index 45c0f6cef6b..f8fe8e0d7d1 100644
--- a/src/core/src/main/kotlin/org/apache/jmeter/threads/openmodel/OpenModelThreadGroup.kt
+++ b/src/core/src/main/kotlin/org/apache/jmeter/threads/openmodel/OpenModelThreadGroup.kt
@@ -27,17 +27,21 @@ import org.apache.jmeter.threads.JMeterThread
import org.apache.jmeter.threads.JMeterThreadMonitor
import org.apache.jmeter.threads.ListenerNotifier
import org.apache.jmeter.threads.TestCompilerHelper
+import org.apache.jmeter.util.JMeterUtils
import org.apache.jorphan.collections.ListedHashTree
import org.apiguardian.api.API
import org.slf4j.LoggerFactory
import java.io.Serializable
import java.lang.Thread.sleep
+import java.lang.invoke.MethodHandles
import java.util.Random
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.Future
+import java.util.concurrent.ThreadFactory
import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
import kotlin.math.roundToLong
@@ -57,6 +61,45 @@ public class OpenModelThreadGroup :
public companion object {
private val log = LoggerFactory.getLogger(OpenModelThreadGroup::class.java)
+ /** Whether to use Java 21 Virtual Threads */
+ private val VIRTUAL_THREADS_ENABLED =
+ JMeterUtils.getPropDefault("jmeter.threads.virtual.enabled", true)
+
+ /** Counter for naming virtual threads */
+ private val virtualThreadCounter = AtomicLong(0)
+
+ /** Cached MethodHandle for creating virtual thread executor (Java 21+) */
+ private val newVirtualThreadExecutor: java.lang.invoke.MethodHandle? = run {
+ if (!VIRTUAL_THREADS_ENABLED) {
+ return@run null
+ }
+ try {
+ val lookup = MethodHandles.lookup()
+ // Verify Thread.ofVirtual() exists (Java 21+)
+ Thread::class.java.getMethod("ofVirtual")
+ // Get the newThreadPerTaskExecutor method
+ val method = Executors::class.java.getMethod(
+ "newThreadPerTaskExecutor",
+ ThreadFactory::class.java
+ )
+ val handle = lookup.unreflect(method)
+ log.info("Virtual threads support enabled for OpenModelThreadGroup (Java 21+)")
+ handle
+ } catch (e: NoSuchMethodException) {
+ log.warn(
+ "Virtual threads requested but not available for OpenModelThreadGroup " +
+ "(requires Java 21+), falling back to platform threads"
+ )
+ null
+ } catch (e: IllegalAccessException) {
+ log.warn(
+ "Virtual threads requested but not available for OpenModelThreadGroup " +
+ "(requires Java 21+), falling back to platform threads"
+ )
+ null
+ }
+ }
+
/** Thread group schedule. See [ThreadSchedule]. */
@Deprecated(
message = "Use OpenModelThreadGroupSchema instead",
@@ -86,6 +129,82 @@ public class OpenModelThreadGroup :
private val houseKeepingThreadPool = Executors.newCachedThreadPool()
private const val serialVersionUID: Long = 1L
+
+ /**
+ * Creates an ExecutorService that uses virtual threads on Java 21+,
+ * or falls back to a cached thread pool on older versions.
+ */
+ private fun createExecutorService(): ExecutorService {
+ val executor = newVirtualThreadExecutor
+ if (executor != null) {
+ try {
+ val factory = ThreadFactory { r ->
+ createVirtualThread(r, "OpenModel-vt-${virtualThreadCounter.incrementAndGet()}")
+ ?: Thread(r).apply { name = "OpenModel-$name" }
+ }
+ val result = executor.invoke(factory) as ExecutorService
+ log.debug("Created virtual thread executor for OpenModelThreadGroup")
+ return result
+ } catch (t: Throwable) {
+ log.warn("Failed to create virtual thread executor, falling back to cached thread pool", t)
+ }
+ }
+ return Executors.newCachedThreadPool()
+ }
+
+ /**
+ * Creates a virtual thread using reflection (for Java 21+ compatibility).
+ */
+ private fun createVirtualThread(runnable: Runnable, name: String): Thread? {
+ return try {
+ val lookup = MethodHandles.lookup()
+ val ofVirtualMethod = Thread::class.java.getMethod("ofVirtual")
+ var builder = lookup.unreflect(ofVirtualMethod).invoke()
+
+ val builderClass = builder.javaClass
+ val nameMethod = findMethodInHierarchy(builderClass, "name", String::class.java)
+ if (nameMethod != null) {
+ builder = lookup.unreflect(nameMethod).invoke(builder, name)
+ }
+
+ val unstartedMethod = findMethodInHierarchy(builderClass, "unstarted", Runnable::class.java)
+ if (unstartedMethod != null) {
+ lookup.unreflect(unstartedMethod).invoke(builder, runnable) as Thread
+ } else {
+ null
+ }
+ } catch (t: Throwable) {
+ log.trace("Failed to create virtual thread", t)
+ null
+ }
+ }
+
+ /**
+ * Finds a method in the class hierarchy, including interfaces.
+ */
+ private fun findMethodInHierarchy(
+ clazz: Class<*>,
+ methodName: String,
+ vararg paramTypes: Class<*>
+ ): java.lang.reflect.Method? {
+ var c: Class<*>? = clazz
+ while (c != null) {
+ try {
+ return c.getMethod(methodName, *paramTypes)
+ } catch (ignored: NoSuchMethodException) {
+ // Try interfaces
+ for (iface in c.interfaces) {
+ try {
+ return iface.getMethod(methodName, *paramTypes)
+ } catch (e: NoSuchMethodException) {
+ // Continue searching
+ }
+ }
+ }
+ c = c.superclass
+ }
+ return null
+ }
}
// A thread pool that executes main workload.
@@ -205,7 +324,7 @@ public class OpenModelThreadGroup :
val rnd = if (seed == 0L) Random() else Random(seed)
val gen = ThreadScheduleProcessGenerator(rnd, parsedSchedule)
val testStartTime = this.startTime
- val executorService = Executors.newCachedThreadPool()
+ val executorService = createExecutorService()
this.executorService = executorService
val starter = ThreadsStarter(testStartTime, executorService, activeThreads, gen) { threadNumber ->
val clonedTree = cloneTree(threadGroupTree)
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 23721c4b4c9..5791663bd1e 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
@@ -315,7 +315,10 @@ duration_assertion_label=Duration in milliseconds\:
duration_assertion_title=Duration Assertion
duration_tooltip=Elapsed time of current running Test
edit=Edit
-editable_checkbox.use_expression=Use Expression
+edit_as_expression_action=Use Expression
+edit_as_expression_tooltip=Switch to expression mode to use variables like ${__P(property)}
+editable_checkbox.true=True
+editable_checkbox.false=False
email_results_title=Email Results
en=English
enable=Enable
@@ -999,6 +1002,13 @@ reportgenerator_summary_total=Total
request_data=Request Data
reset=Reset
response_save_as_md5=Save response as MD5 hash?
+expression_mode_button_tooltip=Switch to expression mode to use variables like ${__P(property)}
+response_processing_title=Response Processing
+response_processing_mode=Processing mode\:
+response_processing_store_compressed=Store response (decompress on access)
+response_processing_fetch_discard=Fetch and discard (headers only)
+response_processing_checksum_encoded_md5=Checksum (MD5 of compressed)
+response_processing_checksum_decoded_md5=Checksum (MD5 of decompressed)
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 ed0f7be54d1..1fb8d18ea7b 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
@@ -310,7 +310,8 @@ duration_assertion_label=Durée en millisecondes \:
duration_assertion_title=Assertion Durée
duration_tooltip=Temps passé depuis le début du test en cours
edit=Editer
-editable_checkbox.use_expression=Utiliser l'expression
+edit_as_expression_action=Utiliser l'expression
+edit_as_expression_tooltip=Passer en mode expression pour utiliser des variables comme ${__P(property)}
email_results_title=Résultat d'email
en=Anglais
enable=Activer
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/kotlin/org/apache/jmeter/gui/action/HtmlReportGeneratorTest.kt b/src/dist-check/src/test/kotlin/org/apache/jmeter/gui/action/HtmlReportGeneratorTest.kt
index f0740bb0efe..6f65c555dd7 100644
--- a/src/dist-check/src/test/kotlin/org/apache/jmeter/gui/action/HtmlReportGeneratorTest.kt
+++ b/src/dist-check/src/test/kotlin/org/apache/jmeter/gui/action/HtmlReportGeneratorTest.kt
@@ -21,6 +21,8 @@ import com.fasterxml.jackson.databind.ObjectMapper
import org.apache.jmeter.junit.JMeterTestCase
import org.apache.jmeter.util.JMeterUtils
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
import org.junit.jupiter.api.fail
import org.junit.jupiter.api.io.TempDir
@@ -36,6 +38,22 @@ class HtmlReportGeneratorTest : JMeterTestCase() {
data class CheckArgumentsCase(val csvPath: String, val userPropertiesPath: String, val outputDirectoryPath: String, val expected: List)
+ /**
+ * Assert that a file exists at the given path relative to a base directory
+ */
+ private fun assertFileExists(baseDir: File, relativePath: String, message: String? = null) {
+ val file = File(baseDir, relativePath)
+ assertTrue(file.exists()) { message ?: "$relativePath should exist" }
+ }
+
+ /**
+ * Assert that a file does NOT exist at the given path relative to a base directory
+ */
+ private fun assertFileNotExists(baseDir: File, relativePath: String, message: String? = null) {
+ val file = File(baseDir, relativePath)
+ assertFalse(file.exists()) { message ?: "$relativePath should NOT exist" }
+ }
+
companion object {
/**
* Combine the given path parts to one path with the correct path separator of the current platform.
@@ -141,4 +159,42 @@ class HtmlReportGeneratorTest : JMeterTestCase() {
fail("First result message should contain '$expectedError', but was '$firstMessage'")
}
}
+
+ @Test
+ fun `report generation creates correct directory structure for HTML and JS files`() {
+ val htmlReportGenerator = HtmlReportGenerator(
+ combine("testfiles", "HTMLReportTestFile.csv"),
+ combine("user.properties"),
+ testDirectory.toString()
+ )
+ htmlReportGenerator.run()
+
+ // Verify directory structure exists
+ assertFileExists(testDirectory, "content")
+ assertFileExists(testDirectory, "content/pages")
+ assertFileExists(testDirectory, "content/js")
+
+ // Verify HTML pages are in correct location (content/pages/)
+ assertFileExists(testDirectory, "content/pages/OverTime.html")
+ assertFileExists(testDirectory, "content/pages/ResponseTimes.html")
+ assertFileExists(testDirectory, "content/pages/Throughput.html")
+ assertFileExists(testDirectory, "content/pages/CustomsGraphs.html")
+
+ // Verify JavaScript files are in correct location (content/js/)
+ assertFileExists(testDirectory, "content/js/dashboard.js")
+ assertFileExists(testDirectory, "content/js/graph.js")
+ assertFileExists(testDirectory, "content/js/dashboard-commons.js")
+ assertFileExists(testDirectory, "content/js/customGraph.js")
+
+ // Verify files are NOT at root level (catches the bug!)
+ assertFileNotExists(testDirectory, "OverTime.html", "OverTime.html should NOT be at root level")
+ assertFileNotExists(testDirectory, "ResponseTimes.html", "ResponseTimes.html should NOT be at root level")
+ assertFileNotExists(testDirectory, "Throughput.html", "Throughput.html should NOT be at root level")
+ assertFileNotExists(testDirectory, "CustomsGraphs.html", "CustomsGraphs.html should NOT be at root level")
+ assertFileNotExists(testDirectory, "dashboard.js", "dashboard.js should NOT be at root level")
+ assertFileNotExists(testDirectory, "graph.js", "graph.js should NOT be at root level")
+
+ // Verify index.html is at root (this should be correct)
+ assertFileExists(testDirectory, "index.html", "index.html should exist at root level")
+ }
}
diff --git a/src/jorphan/src/main/kotlin/org/apache/jorphan/gui/JEditableCheckBox.kt b/src/jorphan/src/main/kotlin/org/apache/jorphan/gui/JEditableCheckBox.kt
index ea825cf9206..30a01d1af44 100644
--- a/src/jorphan/src/main/kotlin/org/apache/jorphan/gui/JEditableCheckBox.kt
+++ b/src/jorphan/src/main/kotlin/org/apache/jorphan/gui/JEditableCheckBox.kt
@@ -17,7 +17,13 @@
package org.apache.jorphan.gui
+import org.apache.jorphan.locale.ComboBoxValue
+import org.apache.jorphan.locale.LocalizedString
+import org.apache.jorphan.locale.PlainValue
+import org.apache.jorphan.locale.ResourceKeyed
+import org.apache.jorphan.locale.ResourceLocalizer
import org.apiguardian.api.API
+import org.jetbrains.annotations.NonNls
import java.awt.Container
import java.awt.FlowLayout
import java.awt.event.ActionEvent
@@ -28,7 +34,6 @@ import javax.swing.JComboBox
import javax.swing.JLabel
import javax.swing.JPanel
import javax.swing.JPopupMenu
-import javax.swing.SwingUtilities
import javax.swing.event.ChangeEvent
/**
@@ -37,8 +42,9 @@ import javax.swing.event.ChangeEvent
*/
@API(status = API.Status.EXPERIMENTAL, since = "5.6")
public open class JEditableCheckBox(
- label: String,
- private val configuration: Configuration
+ label: @NonNls String,
+ private val configuration: Configuration,
+ resourceLocalizer: ResourceLocalizer,
) : JPanel() {
public companion object {
public const val CHECKBOX_CARD: String = "checkbox"
@@ -82,28 +88,31 @@ public open class JEditableCheckBox(
/**
* Supplies the parameters to [JEditableCheckBox].
*/
+ @API(status = API.Status.EXPERIMENTAL, since = "5.6.0")
public data class Configuration(
/** Menu item title to "start editing" the checkbox value. */
- val startEditing: String = "Use Expression",
+ val useExpression: LocalizedString,
+ /** Tooltip for "start editing" button. */
+ val useExpressionTooltip: LocalizedString,
/** The title to be used for "true" value in the checkbox. */
- val trueValue: String = "true",
+ val trueValue: LocalizedString,
/** The title to be used for "false" value in the checkbox. */
- val falseValue: String = "false",
+ val falseValue: LocalizedString,
/** Extra values to be added for the combobox. */
- val extraValues: List = listOf(),
+ val extraValues: List = listOf(),
)
private val cards = CardLayoutWithSizeOfCurrentVisibleElement()
- private val useExpressionAction = object : AbstractAction(configuration.startEditing) {
+ private val useExpressionAction = object : AbstractAction(configuration.useExpression.toString()) {
override fun actionPerformed(e: ActionEvent?) {
cards.next(this@JEditableCheckBox)
+ comboBox.selectedItem = if (checkbox.isSelected) configuration.trueValue else configuration.falseValue
comboBox.requestFocusInWindow()
- fireValueChanged()
}
}
- private val checkbox: JCheckBox = JCheckBox(label).apply {
+ private val checkbox: JCheckBox = JCheckBox(resourceLocalizer.localize(label)).apply {
val cb = this
componentPopupMenu = JPopupMenu().apply {
add(useExpressionAction)
@@ -113,37 +122,25 @@ public open class JEditableCheckBox(
}
}
- private val comboBox: JComboBox = JComboBox().apply {
+ private val comboBox: JComboBox = JComboBox().apply {
isEditable = true
configuration.extraValues.forEach {
addItem(it)
}
addItem(configuration.trueValue)
addItem(configuration.falseValue)
- addActionListener {
- val jComboBox = it.source as JComboBox<*>
- SwingUtilities.invokeLater {
- if (jComboBox.isPopupVisible) {
- fireValueChanged()
- return@invokeLater
- }
- when (val value = jComboBox.selectedItem as String) {
- configuration.trueValue, configuration.falseValue -> {
- checkbox.isSelected = value == configuration.trueValue
- cards.show(this@JEditableCheckBox, CHECKBOX_CARD)
- checkbox.requestFocusInWindow()
- fireValueChanged()
- }
- }
- }
- }
- // TODO: trigger value changed when the text is changed
}
- private val textFieldLabel = JLabel(label).apply {
+ private val textFieldLabel = JLabel(resourceLocalizer.localize(label)).apply {
labelFor = comboBox
}
+ private val expressionButton = JEllipsisButton().apply {
+ // Tooltip will be set via configuration or use default
+ toolTipText = configuration.useExpressionTooltip.toString()
+ addActionListener(useExpressionAction)
+ }
+
@Transient
private var changeEvent: ChangeEvent? = null
@@ -154,6 +151,7 @@ public open class JEditableCheckBox(
Container().apply {
layout = FlowLayout(FlowLayout.LEADING, 0, 0)
add(checkbox)
+ add(expressionButton)
},
CHECKBOX_CARD
)
@@ -176,6 +174,7 @@ public open class JEditableCheckBox(
super.setEnabled(enabled)
checkbox.isEnabled = enabled
comboBox.isEnabled = enabled
+ expressionButton.isEnabled = enabled
useExpressionAction.isEnabled = enabled
}
@@ -190,19 +189,26 @@ public open class JEditableCheckBox(
public var value: Value
get() = when (components.indexOfFirst { it.isVisible }) {
0 -> if (checkbox.isSelected) Value.Boolean.TRUE else Value.Boolean.FALSE
- else -> Value.Text(comboBox.selectedItem as String)
+ else ->
+ when (val value = comboBox.selectedItem) {
+ is ResourceKeyed ->
+ when (value.resourceKey) {
+ configuration.trueValue.resourceKey -> Value.Boolean.TRUE
+ configuration.falseValue.resourceKey -> Value.Boolean.FALSE
+ else -> Value.Text(value.resourceKey)
+ }
+ else -> Value.Text(value?.toString() ?: "")
+ }
}
set(value) {
when (value) {
is Value.Boolean -> {
- comboBox.selectedItem = ""
checkbox.isSelected = value.value
cards.show(this, CHECKBOX_CARD)
}
is Value.Text -> {
- checkbox.isSelected = false
- comboBox.selectedItem = value.value
+ comboBox.selectedItem = PlainValue(value.value)
cards.show(this, EDITABLE_CARD)
}
}
@@ -227,6 +233,7 @@ public open class JEditableCheckBox(
public fun makeSmall() {
JFactory.small(checkbox)
+ JFactory.small(expressionButton)
// We do not make combobox small as the expression migh be hard to read
}
}
diff --git a/src/jorphan/src/main/kotlin/org/apache/jorphan/gui/JEditableComboBox.kt b/src/jorphan/src/main/kotlin/org/apache/jorphan/gui/JEditableComboBox.kt
new file mode 100644
index 00000000000..b1a220679c5
--- /dev/null
+++ b/src/jorphan/src/main/kotlin/org/apache/jorphan/gui/JEditableComboBox.kt
@@ -0,0 +1,229 @@
+/*
+ * 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.jorphan.gui
+
+import org.apache.jorphan.locale.ComboBoxValue
+import org.apache.jorphan.locale.LocalizedString
+import org.apache.jorphan.locale.LocalizedValue
+import org.apache.jorphan.locale.PlainValue
+import org.apache.jorphan.locale.ResourceKeyed
+import org.apache.jorphan.locale.ResourceLocalizer
+import org.apiguardian.api.API
+import org.jetbrains.annotations.NonNls
+import java.awt.Container
+import java.awt.FlowLayout
+import java.awt.event.ActionEvent
+import javax.swing.AbstractAction
+import javax.swing.Box
+import javax.swing.JComboBox
+import javax.swing.JLabel
+import javax.swing.JPanel
+import javax.swing.JPopupMenu
+import javax.swing.event.ChangeEvent
+
+/**
+ * A combo box that can display predefined enum values (with localized text) or switch to
+ * an editable text field for custom expressions like `${__P(property)}`.
+ *
+ * This component uses a CardLayout to switch between:
+ * - A non-editable combo box showing predefined values with localized display text
+ * - An editable combo box allowing custom text input (expressions)
+ *
+ * The component stores resource keys (non-localized) as values, but displays localized
+ * text to the user via a cell renderer.
+ *
+ * Example usage:
+ * ```kotlin
+ * val config = JEditableComboBox.Configuration(
+ * startEditing = JMeterUtils.getResString("editable_combobox_use_expression"),
+ * values = listOf("option_key_1", "option_key_2"),
+ * extraValues = listOf("\${__P(my_property)}", "\${variable_name}")
+ * )
+ * val comboBox = JEditableComboBox("Label:", config)
+ * ```
+ *
+ * @since 6.0.0
+ */
+@API(status = API.Status.EXPERIMENTAL, since = "6.0.0")
+public open class JEditableComboBox(
+ label: @NonNls String,
+ private val configuration: Configuration,
+ localizer: ResourceLocalizer,
+) : JPanel() {
+ public companion object {
+ public const val COMBO_CARD: String = "combo"
+ public const val EDITABLE_CARD: String = "editable"
+ public const val VALUE_PROPERTY: String = "value"
+ }
+
+ /**
+ * Configuration for the editable combo box.
+ *
+ * @property useExpression Text for the menu item to switch to editable mode
+ * @property values List of predefined resource keys (stored values)
+ * @property extraValues Additional template values to show in editable mode (like expressions)
+ */
+ public data class Configuration(
+ val useExpression: LocalizedString,
+ val useExpressionTooltip: LocalizedString,
+ val values: List>,
+ val extraValues: List = listOf()
+ )
+
+ private val cards = CardLayoutWithSizeOfCurrentVisibleElement()
+
+ private val useExpressionAction = object : AbstractAction(configuration.useExpression.toString()) {
+ override fun actionPerformed(e: ActionEvent?) {
+ editableCombo.selectedItem = nonEditableCombo.selectedItem
+ cards.show(this@JEditableComboBox, EDITABLE_CARD)
+ editableCombo.requestFocusInWindow()
+ }
+ }
+
+ private val nonEditableCombo: JComboBox = JComboBox().apply {
+ isEditable = false
+ componentPopupMenu = JPopupMenu().apply {
+ add(useExpressionAction)
+ }
+
+ // Add predefined values
+ configuration.values.forEach {
+ addItem(it)
+ }
+
+ addActionListener {
+ fireValueChanged()
+ }
+ }
+
+ private val editableCombo: JComboBox = JComboBox().apply {
+ isEditable = true
+
+ // Add template expressions first, then predefined values
+ configuration.extraValues.forEach {
+ addItem(it)
+ }
+ configuration.values.forEach {
+ addItem(it)
+ }
+ }
+
+ private val comboLabel = JLabel(localizer.localize(label)).apply {
+ labelFor = nonEditableCombo
+ }
+
+ private val editableLabel = JLabel(localizer.localize(label)).apply {
+ labelFor = editableCombo
+ }
+
+ private val expressionButton = JEllipsisButton().apply {
+ toolTipText = configuration.useExpressionTooltip.toString()
+ addActionListener(useExpressionAction)
+ }
+
+ @Transient
+ private var changeEvent: ChangeEvent? = null
+
+ init {
+ layout = cards
+ add(
+ Container().apply {
+ layout = FlowLayout(FlowLayout.LEADING, 0, 0)
+ add(comboLabel)
+ add(Box.createHorizontalStrut(5))
+ add(nonEditableCombo)
+ add(Box.createHorizontalStrut(3))
+ add(expressionButton)
+ },
+ COMBO_CARD
+ )
+ add(
+ Container().apply {
+ layout = FlowLayout(FlowLayout.LEADING, 0, 0)
+ add(editableLabel)
+ add(Box.createHorizontalStrut(5))
+ add(editableCombo)
+ },
+ EDITABLE_CARD
+ )
+ }
+
+ private var oldValue = value
+
+ override fun setEnabled(enabled: Boolean) {
+ super.setEnabled(enabled)
+ nonEditableCombo.isEnabled = enabled
+ editableCombo.isEnabled = enabled
+ expressionButton.isEnabled = enabled
+ useExpressionAction.isEnabled = enabled
+ }
+
+ private fun fireValueChanged() {
+ val newValue = value
+ if (value != oldValue) {
+ firePropertyChange(VALUE_PROPERTY, oldValue, newValue)
+ oldValue = newValue
+ }
+ }
+
+ /**
+ * Gets or sets the current value (resource key or custom expression).
+ */
+ public var value: ComboBoxValue?
+ get() = when (components.indexOfFirst { it.isVisible }) {
+ 0 -> nonEditableCombo.selectedItem as? ComboBoxValue
+ else -> when (val value = editableCombo.selectedItem) {
+ is ComboBoxValue -> value
+ is String -> PlainValue(value)
+ else -> null
+ }
+ }
+ set(value) {
+ // The user might provide a free-text value which coincides with a resourceKey
+ // For instance, it might be the case when loading a value from jmx test plan
+ val knownValue = value?.let { findKnownValue(it) }
+ if (knownValue != null) {
+ // Predefined value - use non-editable combo
+ nonEditableCombo.selectedItem = knownValue
+ cards.show(this, COMBO_CARD)
+ } else {
+ // Custom expression - use editable combo
+ editableCombo.selectedItem = value
+ cards.show(this, EDITABLE_CARD)
+ }
+ fireValueChanged()
+ }
+
+ private fun findKnownValue(value: ComboBoxValue): ComboBoxValue? {
+ return when (value) {
+ is PlainValue -> {
+ // Plain value might match with one of the known resource keys
+ configuration.values.find { it.value.resourceKey == value.value }
+ }
+ else -> {
+ configuration.values.find { it == value }
+ }
+ }
+ }
+
+ public fun makeSmall() {
+ JFactory.small(comboLabel)
+ JFactory.small(editableLabel)
+ // Note: JFactory.small() doesn't support JComboBox
+ }
+}
diff --git a/src/jorphan/src/main/kotlin/org/apache/jorphan/gui/JEllipsisButton.kt b/src/jorphan/src/main/kotlin/org/apache/jorphan/gui/JEllipsisButton.kt
new file mode 100644
index 00000000000..6e1f0da4c6d
--- /dev/null
+++ b/src/jorphan/src/main/kotlin/org/apache/jorphan/gui/JEllipsisButton.kt
@@ -0,0 +1,28 @@
+/*
+ * 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.jorphan.gui
+
+import javax.swing.plaf.nimbus.NimbusStyle
+
+public class JEllipsisButton : JSquareButton("â‹®") {
+ init {
+ putClientProperty("JComponent.sizeVariant", NimbusStyle.SMALL_KEY)
+ putClientProperty("JButton.square", true)
+ putClientProperty("JButton.thin", true)
+ }
+}
diff --git a/src/jorphan/src/main/kotlin/org/apache/jorphan/gui/JSquareButton.kt b/src/jorphan/src/main/kotlin/org/apache/jorphan/gui/JSquareButton.kt
new file mode 100644
index 00000000000..dbf40351dde
--- /dev/null
+++ b/src/jorphan/src/main/kotlin/org/apache/jorphan/gui/JSquareButton.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.jorphan.gui
+
+import org.jetbrains.annotations.Nls
+import java.awt.Dimension
+import javax.swing.JButton
+
+public open class JSquareButton(title: @Nls String) : JButton(title) {
+ override fun getPreferredSize(): Dimension {
+ val dimension = super.getPreferredSize()
+ return Dimension(dimension.height + 15, dimension.height)
+ }
+}
diff --git a/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/ComboBoxValue.kt b/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/ComboBoxValue.kt
new file mode 100644
index 00000000000..02ac6fbc174
--- /dev/null
+++ b/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/ComboBoxValue.kt
@@ -0,0 +1,40 @@
+/*
+ * 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.jorphan.locale
+
+import org.apiguardian.api.API
+
+/**
+ * Marker interface for values that can be used in a combo box.
+ *
+ * This interface is intended to standardize handling of values for GUI components
+ * like combo boxes. Classes implementing this interface can define how their
+ * values are represented and localized, providing flexibility for different types
+ * of combo box content.
+ *
+ * Implementers may include:
+ * - Simple values (e.g., strings or plain objects).
+ * - Values with additional localization support for display in different locales.
+ * - Wrapper types that encapsulate richer behaviors for combo box items.
+ *
+ * This interface is marked as experimental and may change in future releases.
+ *
+ * @since 6.0.0
+ */
+@API(status = API.Status.EXPERIMENTAL, since = "6.0.0")
+public interface ComboBoxValue
diff --git a/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/LocalizedString.kt b/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/LocalizedString.kt
new file mode 100644
index 00000000000..97edda99918
--- /dev/null
+++ b/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/LocalizedString.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.jorphan.locale
+
+import org.apiguardian.api.API
+import org.jetbrains.annotations.NonNls
+
+@API(status = API.Status.EXPERIMENTAL, since = "6.0.0")
+public class LocalizedString(
+ override val resourceKey: @NonNls String,
+ private val resourceLocalizer: ResourceLocalizer,
+) : ResourceKeyed, ComboBoxValue {
+ override fun toString(): String =
+ resourceLocalizer.localize(resourceKey)
+
+ override fun equals(other: Any?): Boolean =
+ other is LocalizedString && other.resourceKey == resourceKey
+
+ override fun hashCode(): Int =
+ resourceKey.hashCode()
+}
diff --git a/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/LocalizedValue.kt b/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/LocalizedValue.kt
new file mode 100644
index 00000000000..46ac72cacb2
--- /dev/null
+++ b/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/LocalizedValue.kt
@@ -0,0 +1,51 @@
+/*
+ * 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.jorphan.locale
+
+import org.apiguardian.api.API
+
+/**
+ * Represents a localized value that combines an object implementing the [ResourceKeyed] interface
+ * with a localization function. This allows the object's resource key to be localized dynamically
+ * into a string representation.
+ *
+ * @param T The type of the value, which must implement the [ResourceKeyed] interface.
+ * @property value The object implementing [ResourceKeyed], which holds a resource key for localization.
+ * @property localizer A function that takes a resource key string and returns its localized string representation.
+ *
+ * The localized representation of this object is produced by applying the `localizer` function
+ * to the resource key of the `value`.
+ *
+ * The class provides equality and hash code implementations based on the underlying `value`.
+ *
+ * @since 6.0.0
+ */
+@API(status = API.Status.EXPERIMENTAL, since = "6.0.0")
+public class LocalizedValue(
+ public val value: T,
+ private val localizer: ResourceLocalizer,
+) : ResourceKeyed by value, ComboBoxValue {
+ override fun toString(): String =
+ localizer.localize(value.resourceKey)
+
+ override fun equals(other: Any?): Boolean =
+ other is LocalizedValue<*> && other.value == value
+
+ override fun hashCode(): Int =
+ value.hashCode()
+}
diff --git a/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/PlainValue.kt b/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/PlainValue.kt
new file mode 100644
index 00000000000..8523be8769a
--- /dev/null
+++ b/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/PlainValue.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.jorphan.locale
+
+import org.apiguardian.api.API
+
+@JvmInline
+@API(status = API.Status.EXPERIMENTAL, since = "6.0.0")
+public value class PlainValue(public val value: String): ComboBoxValue {
+ override fun toString(): String = value
+}
diff --git a/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/ResourceKeyed.kt b/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/ResourceKeyed.kt
new file mode 100644
index 00000000000..eaaca37e81e
--- /dev/null
+++ b/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/ResourceKeyed.kt
@@ -0,0 +1,63 @@
+/*
+ * 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.jorphan.locale
+
+import org.apiguardian.api.API
+import org.jetbrains.annotations.NonNls
+
+/**
+ * Interface for enum types that provide their own localization resource key for GUI display.
+ *
+ * When an enum implements this interface, the [resourceKey] will be used for:
+ * - Storing the value in JMeter properties (instead of the enum name)
+ * - Looking up localized display text via `JMeterUtils.getResString(resourceKey)`
+ * - Binding to GUI components
+ *
+ * This allows enums to have stable, localization-friendly identifiers that are independent
+ * of the enum constant names.
+ *
+ * Example:
+ * ```java
+ * public enum ResponseMode implements ResourceKeyed {
+ * STORE_COMPRESSED("response_mode_store"),
+ * FETCH_DISCARD("response_mode_discard");
+ *
+ * private final String key;
+ * ResponseMode(String key) { this.key = key; }
+ *
+ * @Override
+ * public String getResourceKey() { return key; }
+ * }
+ * ```
+ *
+ * @since 6.0.0
+ */
+@API(status = API.Status.EXPERIMENTAL, since = "6.0.0")
+public interface ResourceKeyed {
+ /**
+ * Returns the resource key used for localization and property storage.
+ *
+ * This key should:
+ * - Be stable across JMeter versions (don't change it)
+ * - Have a corresponding entry in messages.properties
+ * - Use lowercase with underscores by convention
+ *
+ * @return the resource key for this enum value
+ */
+ public val resourceKey: @NonNls String
+}
diff --git a/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/ResourceLocalizer.kt b/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/ResourceLocalizer.kt
new file mode 100644
index 00000000000..05bea83838a
--- /dev/null
+++ b/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/ResourceLocalizer.kt
@@ -0,0 +1,40 @@
+/*
+ * 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.jorphan.locale
+
+import org.apiguardian.api.API
+import org.jetbrains.annotations.Nls
+import org.jetbrains.annotations.NonNls
+
+/**
+ * Functional interface for localizing resource strings.
+ *
+ * This interface provides a single method, [localize], which takes a non-localized input string
+ * and returns its localized representation. Implementations of this interface define how the
+ * localization is performed, such as looking up translations in a resource bundle or applying
+ * other localization mechanisms.
+ *
+ * The input string is expected to be a resource identifier or key, and the output is a string
+ * appropriate for display in a user interface or other localized context.
+ *
+ * @since 6.0.0
+ */
+@API(status = API.Status.EXPERIMENTAL, since = "6.0.0")
+public fun interface ResourceLocalizer {
+ public fun localize(input: @NonNls String): @Nls String
+}
diff --git a/src/jorphan/src/main/kotlin/org/apache/jorphan/util/EnumUtils.kt b/src/jorphan/src/main/kotlin/org/apache/jorphan/util/EnumUtils.kt
index ecee2105e5d..27213ee68f7 100644
--- a/src/jorphan/src/main/kotlin/org/apache/jorphan/util/EnumUtils.kt
+++ b/src/jorphan/src/main/kotlin/org/apache/jorphan/util/EnumUtils.kt
@@ -18,6 +18,7 @@
@file:JvmName("EnumUtils")
package org.apache.jorphan.util
+import org.apache.jorphan.locale.ResourceKeyed
import org.apiguardian.api.API
import java.util.Collections.unmodifiableList
import java.util.Collections.unmodifiableMap
@@ -39,34 +40,30 @@ private val VALUE_MAP = object : ClassValue