Skip to content

Commit 3117c93

Browse files
vlsiclaude
andcommitted
feat(core): add TestElementUpgrader SPI for load-time property upgrades
SaveService can rename keys and classes on load via NameUpdater, but it cannot transform property *values*. Add a TestElementUpgrader service interface, discovered with ServiceLoader, that upgrades legacy properties on a loaded test plan. SaveService runs all registered upgraders to a fix point after reading a plan, so chained upgrades converge without numbering the migrations. Add the first upgrader: an HTTP-side ResponseProcessingModeUpgrader maps the legacy HTTPSampler.md5 flag to responseProcessingMode. It keys off the property name, so it upgrades both HTTP samplers and HTTP Request Defaults (a ConfigTestElement), which core cannot reference directly. This normalises old plans into the new property on load and on re-save. Runtime correctness for plans built another way still relies on getResponseProcessingMode() falling back to the legacy flag. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0ec1afc commit 3117c93

6 files changed

Lines changed: 372 additions & 1 deletion

File tree

src/core/src/main/java/org/apache/jmeter/save/SaveService.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -455,7 +455,12 @@ private static HashTree readTree(InputStream inputStream, File file)
455455
log.error("Problem loading XML: see above.");
456456
return null;
457457
}
458-
return wrapper.testPlan;
458+
HashTree testPlan = wrapper.testPlan;
459+
// Upgrade legacy property values (e.g. HTTPSampler.md5 -> responseProcessingMode) right after
460+
// load, so the in-memory tree and any re-save use the current representation. NameUpdater only
461+
// renames keys/classes, so value transforms live in TestElementUpgrader services instead.
462+
TestElementUpgraders.upgrade(testPlan);
463+
return testPlan;
459464
} catch (CannotResolveClassException | ConversionException | NoClassDefFoundError e) {
460465
if(file != null) {
461466
throw new IllegalArgumentException("Problem loading XML from:'"+file.getAbsolutePath()+"'. \nCause:\n"+
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to you under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.apache.jmeter.save
19+
20+
import org.apache.jmeter.testelement.TestElement
21+
import org.apache.jorphan.reflect.JMeterService
22+
import org.apiguardian.api.API
23+
24+
/**
25+
* Upgrades a [TestElement] loaded from an older test-plan format, transforming legacy properties
26+
* into their current representation.
27+
*
28+
* Unlike [org.apache.jmeter.util.NameUpdater], which only renames property keys and element
29+
* classes, an upgrader can transform property *values* — for example, a boolean flag into an enum
30+
* resource key.
31+
*
32+
* Implementations are discovered with [java.util.ServiceLoader], so register them with
33+
* `@AutoService(TestElementUpgrader::class)`.
34+
*
35+
* Implementations must be **idempotent** and **forward-only**:
36+
* - running [upgrade] on an already-current element returns `false` and changes nothing;
37+
* - an upgrade never re-creates the legacy shape it just removed.
38+
*
39+
* These rules let [TestElementUpgraders] run every upgrader to a fix point, so chained upgrades
40+
* (one property migrated, then migrated again later) converge regardless of registration order,
41+
* without numbering the migrations.
42+
*
43+
* @since 6.0.0
44+
*/
45+
@JMeterService
46+
@API(status = API.Status.EXPERIMENTAL, since = "6.0.0")
47+
public fun interface TestElementUpgrader {
48+
/**
49+
* Upgrades a single element in place.
50+
*
51+
* @param element the element to inspect and, if it carries a legacy shape, upgrade
52+
* @return `true` if the element was modified, `false` otherwise
53+
*/
54+
public fun upgrade(element: TestElement): Boolean
55+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to you under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.apache.jmeter.save
19+
20+
import org.apache.jmeter.testelement.TestElement
21+
import org.apache.jmeter.util.JMeterUtils
22+
import org.apache.jorphan.collections.HashTree
23+
import org.apache.jorphan.reflect.LogAndIgnoreServiceLoadExceptionHandler
24+
import org.apiguardian.api.API
25+
import org.slf4j.LoggerFactory
26+
import java.util.ServiceLoader
27+
28+
/**
29+
* Discovers [TestElementUpgrader] services and applies them to a loaded test plan.
30+
*
31+
* Each element is upgraded to a fix point: every upgrader is applied to the element repeatedly
32+
* until a pass changes nothing. Because each upgrader is idempotent, forward-only, and touches only
33+
* the element it is given, a chain of upgrades on the same property converges without any ordering
34+
* metadata, and elements can be upgraded independently of each other.
35+
*
36+
* @since 6.0.0
37+
*/
38+
@API(status = API.Status.EXPERIMENTAL, since = "6.0.0")
39+
public object TestElementUpgraders {
40+
private val log = LoggerFactory.getLogger(TestElementUpgraders::class.java)
41+
42+
private val upgraders: List<TestElementUpgrader> = load()
43+
44+
private fun load(): List<TestElementUpgrader> =
45+
try {
46+
JMeterUtils.loadServicesAndScanJars(
47+
TestElementUpgrader::class.java,
48+
ServiceLoader.load(TestElementUpgrader::class.java),
49+
Thread.currentThread().contextClassLoader,
50+
LogAndIgnoreServiceLoadExceptionHandler(log)
51+
).toList()
52+
} catch (e: Exception) {
53+
log.error("Unable to load TestElementUpgrader services", e)
54+
emptyList()
55+
}
56+
57+
/**
58+
* Applies every registered upgrader to every [TestElement] in the tree.
59+
*
60+
* @param tree the loaded test plan to upgrade in place
61+
*/
62+
@JvmStatic
63+
public fun upgrade(tree: HashTree) {
64+
if (upgraders.isEmpty()) {
65+
return
66+
}
67+
// Walk the tree iteratively (no recursion) and upgrade each element as it is visited. Property
68+
// upgrades change only an element's own properties, not the tree structure, and HashTree keys
69+
// by identity (IdentityHashMap), so editing an element in place during the walk is safe and
70+
// needs no separate "collect to a list" pass. A future structural phase would run before this
71+
// one, so the tree shape is already stable here.
72+
val pending = ArrayDeque<HashTree>()
73+
pending.addLast(tree)
74+
while (pending.isNotEmpty()) {
75+
val subTree = pending.removeLast()
76+
for (node in subTree.list()) {
77+
if (node is TestElement) {
78+
upgrade(node)
79+
}
80+
pending.addLast(subTree.getTree(node))
81+
}
82+
}
83+
}
84+
85+
private fun upgrade(element: TestElement) {
86+
// A forward-only chain on one property settles within `upgraders.size` passes; the extra pass
87+
// confirms convergence, and the bound stops a misbehaving (cyclic) upgrader from looping forever.
88+
val maxPasses = upgraders.size + 1
89+
var pass = 0
90+
var changed = true
91+
while (changed && pass < maxPasses) {
92+
changed = false
93+
for (upgrader in upgraders) {
94+
changed = applyUpgrader(upgrader, element) || changed
95+
}
96+
pass++
97+
}
98+
if (changed) {
99+
log.warn(
100+
"TestElement upgraders did not converge for {} after {} passes; some legacy properties may remain",
101+
describe(element), maxPasses
102+
)
103+
}
104+
}
105+
106+
private fun applyUpgrader(upgrader: TestElementUpgrader, element: TestElement): Boolean =
107+
try {
108+
val changed = upgrader.upgrade(element)
109+
if (changed && log.isDebugEnabled) {
110+
log.debug("{} upgraded {}", upgrader.javaClass.name, describe(element))
111+
}
112+
changed
113+
} catch (e: Exception) { // NOSONAR one bad upgrader must not make the whole plan unloadable
114+
// Surfaced at ERROR so it is visible without enabling debug logging: name the element the
115+
// user can recognise, say what happened, and that the element was left as-is.
116+
log.error(
117+
"Failed to upgrade {} from an older JMeter format; leaving it unchanged. Failing upgrader: {}",
118+
describe(element), upgrader.javaClass.name, e
119+
)
120+
false
121+
}
122+
123+
private fun describe(element: TestElement): String =
124+
"element '${element.name}' (${element.javaClass.name})"
125+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to you under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.apache.jmeter.protocol.http.sampler
19+
20+
import com.google.auto.service.AutoService
21+
import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase.ResponseProcessingMode
22+
import org.apache.jmeter.save.TestElementUpgrader
23+
import org.apache.jmeter.testelement.TestElement
24+
import org.apiguardian.api.API
25+
26+
/**
27+
* Upgrades the legacy `HTTPSampler.md5` boolean property to `HTTPSampler.responseProcessingMode`.
28+
*
29+
* It keys off the property name, not the element class, so it upgrades both HTTP samplers and HTTP
30+
* Request Defaults (a `ConfigTestElement`). `md5=true` becomes the decoded-MD5 checksum mode (the
31+
* historical "Save as MD5" behaviour); `md5=false` becomes "store the response", which keeps it as
32+
* an explicit choice that still overrides a value inherited from HTTP Request Defaults.
33+
*
34+
* @since 6.0.0
35+
*/
36+
@AutoService(TestElementUpgrader::class)
37+
@API(status = API.Status.INTERNAL, since = "6.0.0")
38+
public class ResponseProcessingModeUpgrader : TestElementUpgrader {
39+
override fun upgrade(element: TestElement): Boolean {
40+
val schema = HTTPSamplerBaseSchema.INSTANCE
41+
42+
@Suppress("DEPRECATION")
43+
val md5 = element.getPropertyOrNull(schema.storeAsMD5)?.booleanValue ?: return false
44+
// Keep an already-set mode (idempotent, and a newer property wins over the legacy flag).
45+
if (element.getPropertyOrNull(schema.responseProcessingMode.name) == null) {
46+
val mode = if (md5) {
47+
ResponseProcessingMode.CHECKSUM_DECODED_MD5
48+
} else {
49+
ResponseProcessingMode.STORE_COMPRESSED
50+
}
51+
element[schema.responseProcessingMode] = mode.resourceKey
52+
}
53+
@Suppress("DEPRECATION")
54+
element.removeProperty(schema.storeAsMD5)
55+
return true
56+
}
57+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to you under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.apache.jmeter.protocol.http.sampler
19+
20+
import org.apache.jmeter.config.ConfigTestElement
21+
import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase.ResponseProcessingMode
22+
import org.apache.jmeter.save.TestElementUpgraders
23+
import org.apache.jmeter.testelement.property.BooleanProperty
24+
import org.apache.jorphan.collections.ListedHashTree
25+
import org.junit.jupiter.api.Assertions.assertEquals
26+
import org.junit.jupiter.api.Test
27+
28+
/**
29+
* End-to-end check of the [TestElementUpgraders] driver with the ServiceLoader-registered
30+
* [ResponseProcessingModeUpgrader], mirroring what SaveService runs after loading a test plan.
31+
*/
32+
class ResponseProcessingModeUpgradeOnLoadTest {
33+
private val md5 = "HTTPSampler.md5"
34+
private val mode = HTTPSamplerBaseSchema.INSTANCE.responseProcessingMode.name
35+
36+
@Test
37+
fun upgradesBothSamplerAndHttpRequestDefaults() {
38+
val sampler = HTTPSamplerProxy().apply { setProperty(BooleanProperty(md5, true)) }
39+
// HTTP Request Defaults are a ConfigTestElement; the upgrader keys off the property name,
40+
// so it must be upgraded too.
41+
val defaults = ConfigTestElement().apply { setProperty(BooleanProperty(md5, false)) }
42+
43+
val tree = ListedHashTree()
44+
tree.add(sampler)
45+
tree.add(defaults)
46+
47+
TestElementUpgraders.upgrade(tree)
48+
49+
assertEquals(ResponseProcessingMode.CHECKSUM_DECODED_MD5.resourceKey, sampler.getPropertyAsString(mode))
50+
assertEquals("", sampler.getPropertyAsString(md5), "the sampler's legacy md5 must be removed")
51+
52+
assertEquals(ResponseProcessingMode.STORE_COMPRESSED.resourceKey, defaults.getPropertyAsString(mode))
53+
assertEquals("", defaults.getPropertyAsString(md5), "the defaults' legacy md5 must be removed")
54+
}
55+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to you under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.apache.jmeter.protocol.http.sampler
19+
20+
import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase.ResponseProcessingMode
21+
import org.apache.jmeter.testelement.property.BooleanProperty
22+
import org.junit.jupiter.api.Assertions.assertEquals
23+
import org.junit.jupiter.api.Assertions.assertFalse
24+
import org.junit.jupiter.api.Assertions.assertTrue
25+
import org.junit.jupiter.api.Test
26+
27+
class ResponseProcessingModeUpgraderTest {
28+
private val upgrader = ResponseProcessingModeUpgrader()
29+
private val md5 = "HTTPSampler.md5"
30+
private val mode = HTTPSamplerBaseSchema.INSTANCE.responseProcessingMode.name
31+
32+
@Test
33+
fun md5TrueBecomesChecksumDecodedMd5() {
34+
val element = HTTPSamplerProxy()
35+
element.setProperty(BooleanProperty(md5, true))
36+
37+
assertTrue(upgrader.upgrade(element))
38+
assertEquals(ResponseProcessingMode.CHECKSUM_DECODED_MD5.resourceKey, element.getPropertyAsString(mode))
39+
assertEquals("", element.getPropertyAsString(md5), "the legacy md5 property must be removed")
40+
assertFalse(upgrader.upgrade(element), "a second run is a no-op (idempotent)")
41+
}
42+
43+
@Test
44+
fun md5FalseBecomesStoreCompressed() {
45+
val element = HTTPSamplerProxy()
46+
element.setProperty(BooleanProperty(md5, false))
47+
48+
assertTrue(upgrader.upgrade(element))
49+
assertEquals(ResponseProcessingMode.STORE_COMPRESSED.resourceKey, element.getPropertyAsString(mode))
50+
assertEquals("", element.getPropertyAsString(md5))
51+
}
52+
53+
@Test
54+
fun elementWithoutMd5IsUntouched() {
55+
val element = HTTPSamplerProxy()
56+
assertFalse(upgrader.upgrade(element))
57+
assertEquals("", element.getPropertyAsString(mode))
58+
}
59+
60+
@Test
61+
fun existingModeIsKeptAndLegacyMd5Removed() {
62+
val element = HTTPSamplerProxy()
63+
element.setProperty(mode, ResponseProcessingMode.FETCH_AND_DISCARD.resourceKey)
64+
element.setProperty(BooleanProperty(md5, true))
65+
66+
assertTrue(upgrader.upgrade(element))
67+
assertEquals(
68+
ResponseProcessingMode.FETCH_AND_DISCARD.resourceKey,
69+
element.getPropertyAsString(mode),
70+
"an explicit mode must win over the legacy md5"
71+
)
72+
assertEquals("", element.getPropertyAsString(md5))
73+
}
74+
}

0 commit comments

Comments
 (0)