From d11523cd3fe102d67f369c9119864d6e3eb5d3c0 Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Fri, 12 Jun 2026 17:07:21 -0300 Subject: [PATCH 1/9] Initial commit --- .../workflows/ci-jmeter-compatibility.yaml | 12 +- README.md | 9 + docs/jmeter-regression.md | 230 +++ pom.xml | 145 +- .../jmeter/http2/core/HTTP2JettyClient.java | 665 +++++-- .../core/JMeterJettySslContextFactory.java | 5 + .../core/JmeterCompressionHeadersSupport.java | 144 ++ .../core/JmeterHttpClientAttributes.java | 14 + .../core/JmeterHttpClientExceptionMapper.java | 105 ++ .../http1/CustomHttpChannelOverHTTP.java | 20 + .../CustomHttpClientConnectionFactory.java | 47 + .../http1/CustomHttpConnectionOverHTTP.java | 21 + .../http1/CustomHttpSenderOverHTTP.java | 108 ++ .../custom/http1/KeepAliveParityEndPoint.java | 226 +++ .../jmeter/http2/sampler/HTTP2Sampler.java | 138 +- .../sampler/JmxBlazeMeterHttpMigrator.java | 151 ++ .../http2/core/HTTP2JettyClientTest.java | 20 +- .../JmeterCompressionHeadersSupportTest.java | 95 + .../JmeterHttpClientExceptionMapperTest.java | 48 + ...ustomHttpKeepAliveWireIntegrationTest.java | 307 +++ .../http1/KeepAliveParityEndPointTest.java | 32 + .../parity/HttpCacheManagerParityTest.java | 93 + .../HttpClient4PluginParitySupport.java | 107 ++ .../parity/HttpCookieManagerParityTest.java | 85 + .../HttpDisableArgumentsParityTest.java | 87 + .../HttpMirrorFileUploadParityTest.java | 127 ++ .../parity/HttpMirrorItemisedParityTest.java | 242 +++ .../parity/HttpMirrorMultipartParityTest.java | 144 ++ .../http2/parity/HttpMirrorParitySupport.java | 239 +++ .../http2/parity/HttpMirrorParityTest.java | 226 +++ .../parity/HttpMirrorRawBodyParityTest.java | 165 ++ .../parity/HttpRedirectsFollowParityTest.java | 113 ++ .../http2/parity/HttpRedirectsParityTest.java | 119 ++ .../http2/parity/ParityRedirectServer.java | 75 + .../HttpsampleResultComparator.java | 218 +++ .../http2/regression/JmeterDistribution.java | 183 ++ .../JmeterHttpRegressionIntegrationTest.java | 194 ++ .../regression/JmeterRegressionRunner.java | 193 ++ .../regression/JmeterRegressionSupport.java | 242 +++ .../http2/regression/JtlSampleLoader.java | 99 + .../regression/RegressionProtocolProfile.java | 75 + .../jmeter/http2/regression/SampleRecord.java | 64 + .../JmxBlazeMeterHttpMigratorTest.java | 96 + .../jmeter-regression/5.6.3/BUG_62847.jmx | 322 ++++ .../jmeter-regression/5.6.3/Bug54685.jmx | 155 ++ .../5.6.3/HTMLParserTestFile_2.jmx | 124 ++ .../5.6.3/Http4ImplDigestAuth.jmx | 218 +++ .../5.6.3/Http4ImplPreemptiveBasicAuth.jmx | 471 +++++ .../5.6.3/ResponseDecompression.jmx | 249 +++ .../5.6.3/SlowCharsFeature.jmx | 383 ++++ .../jmeter-regression/5.6.3/TEST_GET.jmx | 7 + .../jmeter-regression/5.6.3/TEST_HTTP.jmx | 1657 +++++++++++++++++ .../jmeter-regression/5.6.3/TEST_HTTPS.jmx | 194 ++ .../5.6.3/TestCookieManager.jmx | 628 +++++++ .../5.6.3/TestHeaderManager.jmx | 372 ++++ .../jmeter-regression/5.6.3/TestKeepAlive.jmx | 465 +++++ .../5.6.3/TestRedirectionPolicies.jmx | 465 +++++ .../5.6.3/jmeter-batch.properties | 40 + .../jmeter-regression/5.6.3/log4j2-batch.xml | 49 + .../5.6.3/testfiles/HTMLParserTestFile_2.html | 1277 +++++++++++++ .../HTMLParserTestFile_2_files/halfbanner.htm | 6 + .../halfbanner_data/2011-na-234x60.png | Bin 0 -> 5421 bytes .../http-config-example.png | Bin 0 -> 3064 bytes .../jakarta-logo.gif | Bin 0 -> 8584 bytes .../HTMLParserTestFile_2_files/logo.jpg | Bin 0 -> 8886 bytes .../HTMLParserTestFile_2_files/scoping1.png | Bin 0 -> 2395 bytes .../HTMLParserTestFile_2_files/scoping2.png | Bin 0 -> 2641 bytes .../HTMLParserTestFile_2_files/scoping3.png | Bin 0 -> 3260 bytes .../HTMLParserTestFile_2_files/style.css | 39 + .../jmeter-regression/5.6.3/user.properties | 2 + 70 files changed, 12673 insertions(+), 178 deletions(-) create mode 100644 docs/jmeter-regression.md create mode 100644 src/main/java/com/blazemeter/jmeter/http2/core/JmeterCompressionHeadersSupport.java create mode 100644 src/main/java/com/blazemeter/jmeter/http2/core/JmeterHttpClientAttributes.java create mode 100644 src/main/java/com/blazemeter/jmeter/http2/core/JmeterHttpClientExceptionMapper.java create mode 100644 src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/CustomHttpChannelOverHTTP.java create mode 100644 src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/CustomHttpClientConnectionFactory.java create mode 100644 src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/CustomHttpConnectionOverHTTP.java create mode 100644 src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/CustomHttpSenderOverHTTP.java create mode 100644 src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/KeepAliveParityEndPoint.java create mode 100644 src/main/java/com/blazemeter/jmeter/http2/sampler/JmxBlazeMeterHttpMigrator.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/JmeterCompressionHeadersSupportTest.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/JmeterHttpClientExceptionMapperTest.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/CustomHttpKeepAliveWireIntegrationTest.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/KeepAliveParityEndPointTest.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/parity/HttpCacheManagerParityTest.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/parity/HttpClient4PluginParitySupport.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/parity/HttpCookieManagerParityTest.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/parity/HttpDisableArgumentsParityTest.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/parity/HttpMirrorFileUploadParityTest.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/parity/HttpMirrorItemisedParityTest.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/parity/HttpMirrorMultipartParityTest.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/parity/HttpMirrorParitySupport.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/parity/HttpMirrorParityTest.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/parity/HttpMirrorRawBodyParityTest.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/parity/HttpRedirectsFollowParityTest.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/parity/HttpRedirectsParityTest.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/parity/ParityRedirectServer.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/regression/HttpsampleResultComparator.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/regression/JmeterDistribution.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/regression/JmeterHttpRegressionIntegrationTest.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/regression/JmeterRegressionRunner.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/regression/JmeterRegressionSupport.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/regression/JtlSampleLoader.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/regression/RegressionProtocolProfile.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/regression/SampleRecord.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/sampler/JmxBlazeMeterHttpMigratorTest.java create mode 100644 src/test/resources/jmeter-regression/5.6.3/BUG_62847.jmx create mode 100644 src/test/resources/jmeter-regression/5.6.3/Bug54685.jmx create mode 100644 src/test/resources/jmeter-regression/5.6.3/HTMLParserTestFile_2.jmx create mode 100644 src/test/resources/jmeter-regression/5.6.3/Http4ImplDigestAuth.jmx create mode 100644 src/test/resources/jmeter-regression/5.6.3/Http4ImplPreemptiveBasicAuth.jmx create mode 100644 src/test/resources/jmeter-regression/5.6.3/ResponseDecompression.jmx create mode 100644 src/test/resources/jmeter-regression/5.6.3/SlowCharsFeature.jmx create mode 100644 src/test/resources/jmeter-regression/5.6.3/TEST_GET.jmx create mode 100644 src/test/resources/jmeter-regression/5.6.3/TEST_HTTP.jmx create mode 100644 src/test/resources/jmeter-regression/5.6.3/TEST_HTTPS.jmx create mode 100644 src/test/resources/jmeter-regression/5.6.3/TestCookieManager.jmx create mode 100644 src/test/resources/jmeter-regression/5.6.3/TestHeaderManager.jmx create mode 100644 src/test/resources/jmeter-regression/5.6.3/TestKeepAlive.jmx create mode 100644 src/test/resources/jmeter-regression/5.6.3/TestRedirectionPolicies.jmx create mode 100644 src/test/resources/jmeter-regression/5.6.3/jmeter-batch.properties create mode 100644 src/test/resources/jmeter-regression/5.6.3/log4j2-batch.xml create mode 100644 src/test/resources/jmeter-regression/5.6.3/testfiles/HTMLParserTestFile_2.html create mode 100644 src/test/resources/jmeter-regression/5.6.3/testfiles/HTMLParserTestFile_2_files/halfbanner.htm create mode 100644 src/test/resources/jmeter-regression/5.6.3/testfiles/HTMLParserTestFile_2_files/halfbanner_data/2011-na-234x60.png create mode 100644 src/test/resources/jmeter-regression/5.6.3/testfiles/HTMLParserTestFile_2_files/http-config-example.png create mode 100644 src/test/resources/jmeter-regression/5.6.3/testfiles/HTMLParserTestFile_2_files/jakarta-logo.gif create mode 100644 src/test/resources/jmeter-regression/5.6.3/testfiles/HTMLParserTestFile_2_files/logo.jpg create mode 100644 src/test/resources/jmeter-regression/5.6.3/testfiles/HTMLParserTestFile_2_files/scoping1.png create mode 100644 src/test/resources/jmeter-regression/5.6.3/testfiles/HTMLParserTestFile_2_files/scoping2.png create mode 100644 src/test/resources/jmeter-regression/5.6.3/testfiles/HTMLParserTestFile_2_files/scoping3.png create mode 100644 src/test/resources/jmeter-regression/5.6.3/testfiles/HTMLParserTestFile_2_files/style.css create mode 100644 src/test/resources/jmeter-regression/5.6.3/user.properties diff --git a/.github/workflows/ci-jmeter-compatibility.yaml b/.github/workflows/ci-jmeter-compatibility.yaml index b0170a7..b585738 100644 --- a/.github/workflows/ci-jmeter-compatibility.yaml +++ b/.github/workflows/ci-jmeter-compatibility.yaml @@ -43,13 +43,23 @@ jobs: uses: actions/cache@v5 with: path: ${{ github.workspace }}/.jmeter - key: jmeter-${{ runner.os }}-${{ hashFiles('pom.xml', 'src/test/resources/jmeter/**') }} + key: jmeter-${{ runner.os }}-${{ hashFiles('pom.xml', 'src/test/resources/jmeter/**', 'src/test/resources/jmeter-regression/**') }} restore-keys: | jmeter-${{ runner.os }}- - name: Build project and jmeter-test bundle run: xvfb-run -a mvn -U --batch-mode clean install + - name: Run JMeter HTTP parity regression (all tiers) + run: >- + xvfb-run -a mvn -Pjmeter-regression + -Dcheckstyle.skip=true + -DskipTests + -Djmeter.regression.tier=all + -Djmeter.regression.tolerateExternalServiceDrift=true + --batch-mode + verify + - name: Run compatibility matrix on Java 17 working-directory: target/jmeter-test env: diff --git a/README.md b/README.md index f833e90..bc268fd 100644 --- a/README.md +++ b/README.md @@ -427,6 +427,15 @@ Uses **Maven 3** from the repository root: Artifacts land under **`target/`**. Compilation uses the **`jmeter.version`** declared in **`pom.xml`**; at runtime install the packaged JAR against the JMeter build you intend to run and validate with a short smoke plan. +### HTTP parity regression (JMeter 5.6.3 test plans) + +To compare **HttpClient4** with the migrated **BlazeMeter HTTP** sampler against Apache’s official `bin/testfiles` JMX plans, see **[docs/jmeter-regression.md](docs/jmeter-regression.md)**. + +```bash +mvn -Dcheckstyle.skip=true package +mvn -Pjmeter-regression verify +``` + ## License diff --git a/docs/jmeter-regression.md b/docs/jmeter-regression.md new file mode 100644 index 0000000..463d781 --- /dev/null +++ b/docs/jmeter-regression.md @@ -0,0 +1,230 @@ +# JMeter HTTP regression parity tests + + + +This suite compares Apache JMeter **HttpClient4** (reference) with the migrated **BlazeMeter HTTP** sampler (`HTTP2Sampler`) using official JMeter `bin/testfiles` plans from release **5.6.3**. + + + +## What it does + + + +1. Runs the stock JMX with `-Jjmeter.httpsampler=HttpClient4` (no plugin). + +2. Migrates HTTP Request samplers headlessly via `JmxBlazeMeterHttpMigrator`. + +3. Runs the migrated plan with the plugin installed under `lib/ext/`. + +4. Compares sample trees semantically (`HttpsampleResultComparator`): labels, success, response codes/messages, bodies, and normalized response headers. Timings and volatile headers are ignored. + + + +## Regression tiers + + + +| Tier | Maven | Plans | Notes | + +|------|-------|-------|-------| + +| **1** | `-Pjmeter-regression` | `TEST_HTTP`, `ResponseDecompression`, `TestHeaderManager`, `TestCookieManager` | Default CI; mirror + managers | + +| **4 (F4)** | `-Pjmeter-regression-f4` | `HTMLParserTestFile_2`, `TEST_HTTPS`, `Http4ImplDigestAuth`, `Http4ImplPreemptiveBasicAuth` | HTTPS + embedded HTML + auth | + +| **Flaky** | `-Pjmeter-regression-flaky` | `TestKeepAlive`, `TestRedirectionPolicies` | External services; opt-in only | + +| **5 (F5)** | `mvn test -Dtest=com.blazemeter.jmeter.http2.parity.*` | JUnit ports of Apache HTTP module tests | HttpClient4 vs plugin in-process; HTTP/1.1 | + +`BUG_62847` / `Bug54685` are vendored under `5.6.3/` but exercise JMeter controllers/Java samplers only — not HTTP plugin parity. + +`SlowCharsFeature` (optional): run with `-Djmeter.regression.tests=SlowCharsFeature -Djmeter.regression.enableSlowChars=true` once Jetty supports HttpClient4-style **CPS** throttling (`httpclient.socket.*.cps`). See [SlowCharsFeature](#slowcharsfeature) below. + +### HEAD method + +HttpClient4 supports `HEAD` for redirects and all standard samplers. The BlazeMeter client maps Jetty `Request.method("HEAD")` the same way; it was previously rejected by `SUPPORTED_METHODS` in `HTTP2JettyClient` (not a Jetty limitation). `HEAD` must not send a request body; response bodies may still be buffered internally but JMeter parity compares status, headers, and redirect semantics. + +### SlowCharsFeature + +Apache batch plan `SlowCharsFeature.jmx`: + +1. A setup thread sets `httpclient.socket.http.cps` and `httpclient.socket.https.cps` to **1500** (bytes per second). +2. The main sampler requests `https://jmeter.apache.org/...` with `Range: bytes=0-7000` (partial content). +3. Assertions expect **HTTP 206**, ~7001 bytes, and **elapsed time > 5s** (download throttled to ~1.5 KB/s). + +HttpClient4 implements CPS by wrapping socket streams with a rate limiter (`org.apache.http.impl.conn.SocketFactory` / connection socket config). **Jetty has no equivalent property**: you would need a custom `ClientConnector` / `EndPoint` wrapper that throttles `fill()`/`flush()` per connection, and decide how CPS applies across HTTP/2 multiplexing and HTTP/3 QUIC streams (HC4’s model is per-TCP-socket). Until that exists, the plan stays behind `-Djmeter.regression.enableSlowChars=true`. + +`TestKeepAlive` is compared on **http1-only** only (HttpClient4 never speaks HTTP/2; keep-alive / `Connection: close` are HTTP/1.1 semantics). + + + +Use `-Djmeter.regression.tier=4|flaky|all` instead of a profile, or override with `-Djmeter.regression.tests=PlanA,PlanB`. + + + +### SSL keystore / truststore ([PR #112](https://github.com/Blazemeter/jmeter-http-plugin/pull/112)) + + + +F4 includes `TEST_HTTPS` (public TLS hosts) and auth JMX that may use custom stores. **Do not reimplement** keystore path resolution or trust-manager parity in this harness — that work belongs in [PR #112](https://github.com/Blazemeter/jmeter-http-plugin/pull/112) (`SslStorePathResolver`, `JMeterJettySslContextFactory` trust override). + + + +Until #112 is merged, F4 parity gaps involving **relative keystore paths**, **PKCS#11**, or **trust-all vs PKIX** should be fixed in that PR branch, not duplicated here. + + + +## Prerequisites + + + +- **Java 17+** + +- **Maven 3.6+** + +- Network access (first run downloads JMeter 5.6.3; tier 1/4 plans may call external hosts). + + + +## Build the plugin JAR + + + +```bash + +mvn -Dcheckstyle.skip=true package + +``` + + + +## Run regression tests + + + +```bash + +mvn -Pjmeter-regression verify + +``` + + + +### Options + + + +| Property | Default | Description | + +|----------|---------|-------------| + +| `jmeter.regression` | `false` (`true` with `-Pjmeter-regression*`) | Must be `true` to execute regression ITs | + +| `jmeter.regression.tests` | tier 1 list (see `pom.xml`) | Comma-separated JMX base names (no `.jmx`) | + +| `jmeter.regression.tier` | _(unset)_ | `1`, `4`, `flaky`, or `all` when `jmeter.regression.tests` is unset | + +| `jmeter.regression.version` | `5.6.3` | JMeter distribution version to download | + +| `jmeter.home` | _(auto)_ | Use an existing JMeter install instead of downloading | + +| `it.http3` | `false` | Also run each plan with HTTP/3 profile | + +| `jmeter.regression.protocol` | _(all profiles)_ | Filter: `http1-only`, `http2`, or `http3` (with `it.http3`) | + +| `jmeter.regression.timeoutMinutes` | `10` | Per-run timeout | + +| `jmeter.regression.tolerateExternalServiceDrift` | `false` (auto for F4 auth + flaky plans) | Skip strict compare when ref/plugin saw 5xx vs 2xx on the same sample (sequential runs vs flaky hosts) | + + + +### Examples + + + +Tier 1 only, HTTP/1.1 profile: + + + +```bash + +mvn -Pjmeter-regression -Djmeter.regression.protocol=http1-only verify + +``` + + + +F4 — start with local HTML parser (no network): + + + +```bash + +mvn -Pjmeter-regression-f4 -Djmeter.regression.tests=HTMLParserTestFile_2 verify + +``` + + + +Full F4 suite: + + + +```bash + +mvn -Pjmeter-regression-f4 verify + +``` + + + +Flaky / optional batch plans: + + + +```bash + +mvn -Pjmeter-regression-flaky verify + +``` + + + +### F5 — JUnit parity (Apache `src/protocol/http` tests) + +Runs in the normal unit-test phase (`mvn test`), not Failsafe. Each test executes the same sampler configuration through **HttpClient4** and **HTTP2JettyClient** (HTTP/1.1 forced). + +| Class | Apache source | +|-------|----------------| +| `HttpMirrorParityTest` | `TestHTTPSamplersAgainstHttpMirrorServer` (GET/PUT, POST urlencoded/multipart/raw, file upload) | +| `HttpMirrorItemisedParityTest` | same source — `itemised_testPostRequest_UrlEncoded` (0–7), `itemised_testGetRequest_Parameters` (0–5) | +| `HttpMirrorMultipartParityTest` | same source — `testPostRequest_FormMultipart` (0–6) | +| `HttpMirrorRawBodyParityTest` | same source — `testPostRequest_BodyFromParameterValues` (0–9) | +| `HttpMirrorFileUploadParityTest` | same source — `testPostRequest_FileUpload` (0–2) | +| `HttpRedirectsParityTest` | `TestRedirects` (301–308, no follow; incl. HEAD/PUT/DELETE) | +| `HttpRedirectsFollowParityTest` | redirect follow (`followRedirects` + `autoRedirects`) | +| `HttpCookieManagerParityTest` | `TestHC4CookieManager` (set/echo cookies) | +| `HttpCacheManagerParityTest` | `TestCacheManagerHC4` (embedded cache hit) | +| `HttpDisableArgumentsParityTest` | skippable args (5.6.3); `enabled` checkbox → JMeter 5.7+ | + +```bash +mvn test -Dcheckstyle.skip=true -Dtest=com.blazemeter.jmeter.http2.parity.* +``` + + + +## CI + + + +`ci-jmeter-compatibility.yaml` runs **all regression tiers** (`tier=all`: tier 1 + F4 + flaky) via `-Pjmeter-regression` after `mvn clean install` (which also runs F5 parity via Surefire). External-service drift tolerance is enabled in CI for auth/keep-alive plans. + + + +## Resources + + + +Vendored under `src/test/resources/jmeter-regression/5.6.3/` from [apache/jmeter rel/v5.6.3 bin/testfiles](https://github.com/apache/jmeter/tree/rel/v5.6.3/bin/testfiles). + + diff --git a/pom.xml b/pom.xml index d6ce80b..747250f 100644 --- a/pom.xml +++ b/pom.xml @@ -30,7 +30,11 @@ UTF-8 UTF-8 - 5.4.1 + 5.6.3 + 5.6.3 + TEST_HTTP,ResponseDecompression,TestHeaderManager,TestCookieManager + HTMLParserTestFile_2,TEST_HTTPS,Http4ImplDigestAuth,Http4ImplPreemptiveBasicAuth + TestKeepAlive,TestRedirectionPolicies 12.1.6 1.20.0 @@ -49,12 +53,24 @@ ${jmeter.version} provided + + org.apache.jmeter + ApacheJMeter_components + ${jmeter.version} + provided + + + org.apache.jmeter + ApacheJMeter_java + ${jmeter.version} + test + - + org.apache.httpcomponents httpcore - 4.4.14 + 4.4.16 compile @@ -63,10 +79,16 @@ 4.5.14 compile + + commons-io + commons-io + 2.15.1 + test + org.apache.commons commons-lang3 - 3.12.0 + 3.14.0 compile @@ -478,6 +500,9 @@ **/*IntegrationTest.java + + **/regression/** + @@ -632,5 +657,117 @@ + + jmeter-regression + + true + true + true + + + + + org.apache.maven.plugins + maven-surefire-plugin + + true + + + + org.apache.maven.plugins + maven-failsafe-plugin + + false + + **/regression/**/*IntegrationTest.java + + + + ${jmeter.regression} + ${jmeter.regression.tests} + ${jmeter.regression.tier} + ${jmeter.regression.version} + + + + + + + + jmeter-regression-f4 + + true + true + true + 4 + ${jmeter.regression.tier4.tests} + + + + + org.apache.maven.plugins + maven-surefire-plugin + + true + + + + org.apache.maven.plugins + maven-failsafe-plugin + + false + + **/regression/**/*IntegrationTest.java + + + + ${jmeter.regression} + ${jmeter.regression.tests} + ${jmeter.regression.tier} + ${jmeter.regression.version} + + + + + + + + jmeter-regression-flaky + + true + true + true + flaky + ${jmeter.regression.flaky.tests} + + + + + org.apache.maven.plugins + maven-surefire-plugin + + true + + + + org.apache.maven.plugins + maven-failsafe-plugin + + false + + **/regression/**/*IntegrationTest.java + + + + ${jmeter.regression} + ${jmeter.regression.tests} + ${jmeter.regression.tier} + ${jmeter.regression.version} + + + + + + diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java index 2648ffc..ea9ca5a 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java @@ -2,6 +2,7 @@ import static com.blazemeter.jmeter.http2.core.LowLevelDebugLog.lowLevelDebug; +import com.blazemeter.jmeter.http2.core.jetty.custom.http1.CustomHttpClientConnectionFactory; import com.blazemeter.jmeter.http2.core.jetty.custom.http2.CustomClientConnectionFactoryOverHTTP2; import com.blazemeter.jmeter.http2.core.jetty.custom.http3.CustomClientConnectionFactoryOverHTTP3; import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler; @@ -9,6 +10,8 @@ import com.github.luben.zstd.ZstdInputStream; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.io.UnsupportedEncodingException; @@ -46,6 +49,7 @@ import java.util.regex.Pattern; import java.util.stream.StreamSupport; import java.util.zip.GZIPInputStream; +import java.util.zip.Inflater; import java.util.zip.InflaterInputStream; import org.apache.commons.lang3.StringUtils; import org.apache.jmeter.protocol.http.control.AuthManager; @@ -55,9 +59,11 @@ import org.apache.jmeter.protocol.http.control.Header; import org.apache.jmeter.protocol.http.control.HeaderManager; import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; +import org.apache.jmeter.samplers.SampleResult; import org.apache.jmeter.protocol.http.util.HTTPArgument; import org.apache.jmeter.protocol.http.util.HTTPConstants; import org.apache.jmeter.protocol.http.util.HTTPFileArg; +import org.apache.jmeter.services.FileServer; import org.apache.jmeter.testelement.property.JMeterProperty; import org.apache.jmeter.util.JMeterUtils; import org.brotli.dec.BrotliInputStream; @@ -81,7 +87,6 @@ import org.eclipse.jetty.client.Response; import org.eclipse.jetty.client.RetryableRequestException; import org.eclipse.jetty.client.StringRequestContent; -import org.eclipse.jetty.client.transport.HttpClientConnectionFactory; import org.eclipse.jetty.client.transport.HttpClientTransportDynamic; import org.eclipse.jetty.compression.brotli.BrotliCompression; import org.eclipse.jetty.compression.client.CompressionContentDecoderFactory; @@ -132,8 +137,8 @@ public class HTTP2JettyClient { "HTTP2JettyClient build: host-header-filter+http3-always-v2026-01-26"; private static final boolean FORCE_HTTP2_ONLY = false; private static final Set SUPPORTED_METHODS = new HashSet<>(Arrays - .asList(HTTPConstants.GET, HTTPConstants.POST, HTTPConstants.PUT, HTTPConstants.PATCH, - HTTPConstants.OPTIONS, HTTPConstants.DELETE)); + .asList(HTTPConstants.GET, HTTPConstants.HEAD, HTTPConstants.POST, HTTPConstants.PUT, + HTTPConstants.PATCH, HTTPConstants.OPTIONS, HTTPConstants.DELETE)); private static final Set METHODS_WITH_BODY = new HashSet<>(Arrays .asList(HTTPConstants.POST, HTTPConstants.PUT, HTTPConstants.PATCH)); private static final Path ALPN_DEBUG_LOG_PATH = resolveAlpnLogPath(); @@ -269,7 +274,7 @@ public HTTP2JettyClient(boolean http1UpgradeRequired, String name, lowLevelDebug("Could not set SSL protocol explicitly", e); } - ClientConnectionFactory.Info http11 = HttpClientConnectionFactory.HTTP11; + ClientConnectionFactory.Info http11 = CustomHttpClientConnectionFactory.CUSTOM_HTTP11; HTTP2Client http2Client = new HTTP2Client(clientConnector); enableFrameLoggingIfConfigured(http2Client); @@ -1082,7 +1087,7 @@ private HttpClient createHTTP11OnlyClient(String name) throws Exception { ClientConnector clientConnector = createClientConnector(name + "-http11-fallback"); // Create transport with ONLY HTTP/1.1 (no HTTP/2) - ClientConnectionFactory.Info http11 = HttpClientConnectionFactory.HTTP11; + ClientConnectionFactory.Info http11 = CustomHttpClientConnectionFactory.CUSTOM_HTTP11; HttpClientTransport transport = new HttpClientTransportDynamic(clientConnector, http11); lowLevelDebug("HttpClientTransportDynamic configured with HTTP/1.1 only (fallback mode)"); @@ -1120,26 +1125,20 @@ public HTTPSampleResult retryWithHTTP11Only(HTTP2Sampler sampler, HTTPSampleResu lowLevelDebug("Retrying request with HTTP/1.1 only due to protocol_error"); URL url = result.getURL(); - clearContentDecoders(httpClientHttp1Only); - - // Build a new request with the shared HTTP/1.1-only client (keeps auth config) Request http11Request = httpClientHttp1Only.newRequest(url.toURI()) .method(result.getHTTPMethod()) .timeout(requestTimeout, TimeUnit.MILLISECONDS) .followRedirects(sampler.getAutoRedirects()); - - // Copy headers from sampler + http11Request.attribute(JmeterHttpClientAttributes.USE_KEEPALIVE, sampler.getUseKeepAlive()); if (sampler.getHeaderManager() != null) { setHeaders(http11Request, url, sampler.getHeaderManager()); } ensureHostHeader(http11Request, url); + ensureEmptyUserAgentHeader(http11Request); + reapplyConnectionKeepAliveHeader(http11Request); + configureContentDecodersAndCapture(httpClientHttp1Only, http11Request); + setBody(http11Request, sampler, result, false); - configureContentDecoders(httpClientHttp1Only, http11Request); - - // Copy body if present - setBody(http11Request, sampler, result); - - // Send request lowLevelDebug("Sending HTTP/1.1 fallback request"); ContentResponse response = http11Request.send(); @@ -1171,49 +1170,12 @@ private ContentResponse sendWithHTTP11Only(Request originalRequest, lowLevelDebug("Retrying request with HTTP/1.1 only: method={}, URI={}", originalRequest.getMethod(), uri); - clearContentDecoders(httpClientHttp1Only); - try { - // Rebuild the request with the shared HTTP/1.1-only client - Request http11Request = httpClientHttp1Only.newRequest(uri) - .method(originalRequest.getMethod()) - .timeout(requestTimeout, TimeUnit.MILLISECONDS) - .followRedirects(originalRequest.isFollowRedirects()); - - // Copy headers from original request - if (originalRequest.getHeaders() != null) { - HttpFields originalHeaders = originalRequest.getHeaders(); - HttpFields requestHeaders = http11Request.getHeaders(); - if (requestHeaders instanceof HttpFields.Mutable) { - HttpFields.Mutable newHeaders = (HttpFields.Mutable) requestHeaders; - originalHeaders.forEach(field -> { - // Skip HTTP/2 pseudo-headers (they don't exist in HTTP/1.1) - String name = field.getName(); - if (!name.startsWith(":")) { - newHeaders.put(name, field.getValue()); - } - }); - } - // Note: In Jetty 12, Request headers are typically mutable. - // If they're not mutable, the headers from the original request - // will be lost, but this is an edge case. - } - ensureHostHeader(http11Request, uri); - - configureContentDecoders(httpClientHttp1Only, http11Request); - - // Copy body if present - if (originalRequest.getBody() != null) { - http11Request.body(originalRequest.getBody()); - } - - // Send request and wait for response + Request http11Request = buildHttp11FallbackRequest(originalRequest); lowLevelDebug("Sending HTTP/1.1 fallback request"); ContentResponse response = http11Request.send(); - lowLevelDebug("HTTP/1.1 fallback request succeeded: status={}, version={}", response.getStatus(), response.getVersion()); - return response; } catch (Exception e) { LOG.error("HTTP/1.1 fallback also failed for URI: {}", uri, e); @@ -1221,6 +1183,117 @@ private ContentResponse sendWithHTTP11Only(Request originalRequest, } } + private Request buildHttp11FallbackRequest(Request originalRequest) { + URI uri = originalRequest.getURI(); + clearContentDecoders(httpClientHttp1Only); + Request http11Request = httpClientHttp1Only.newRequest(uri) + .method(originalRequest.getMethod()) + .timeout(requestTimeout, TimeUnit.MILLISECONDS) + .followRedirects(originalRequest.isFollowRedirects()); + Object useKeepAlive = + originalRequest.getAttributes().get(JmeterHttpClientAttributes.USE_KEEPALIVE); + if (useKeepAlive != null) { + http11Request.attribute(JmeterHttpClientAttributes.USE_KEEPALIVE, useKeepAlive); + } + http11Request.attribute(JmeterHttpClientAttributes.H2C_FALLBACK_ATTEMPTED, Boolean.TRUE); + if (originalRequest.getHeaders() != null) { + HttpFields originalHeaders = originalRequest.getHeaders(); + HttpFields requestHeaders = http11Request.getHeaders(); + if (requestHeaders instanceof HttpFields.Mutable) { + HttpFields.Mutable newHeaders = (HttpFields.Mutable) requestHeaders; + originalHeaders.forEach(field -> { + String name = field.getName(); + if (name.startsWith(":") || isH2cUpgradeHeader(name, field.getValue())) { + return; + } + newHeaders.put(name, field.getValue()); + }); + } + } + ensureHostHeader(http11Request, uri); + ensureEmptyUserAgentHeader(http11Request); + reapplyConnectionKeepAliveHeader(http11Request); + configureContentDecodersAndCapture(httpClientHttp1Only, http11Request); + if (originalRequest.getBody() != null) { + http11Request.body(originalRequest.getBody()); + } + return http11Request; + } + + private static boolean isH2cUpgradeHeader(String name, String value) { + if (HttpHeader.UPGRADE.is(name) || HttpHeader.HTTP2_SETTINGS.is(name)) { + return true; + } + if (HttpHeader.CONNECTION.is(name) && value != null) { + String lower = value.toLowerCase(Locale.ROOT); + return lower.contains("upgrade") || lower.contains("http2-settings"); + } + return false; + } + + private boolean wasH2cUpgradeAttempt(Request request) { + if (request == null) { + return false; + } + Object protocol = request.getAttributes().get(HttpUpgrader.PROTOCOL_ATTRIBUTE); + if ("h2c".equals(protocol)) { + return true; + } + HttpFields headers = request.getHeaders(); + if (headers == null) { + return false; + } + String upgrade = headers.get(HttpHeader.UPGRADE); + return upgrade != null && upgrade.toLowerCase(Locale.ROOT).contains("h2c"); + } + + private boolean shouldRetryAfterFailedH2cUpgrade(Request request, ContentResponse response) { + if (!enableHttp1 || !http1UpgradeRequired || request == null || response == null) { + return false; + } + URI uri = request.getURI(); + if (uri == null || !"http".equalsIgnoreCase(uri.getScheme())) { + return false; + } + if (Boolean.TRUE.equals( + request.getAttributes().get(JmeterHttpClientAttributes.H2C_FALLBACK_ATTEMPTED))) { + return false; + } + if (!wasH2cUpgradeAttempt(request)) { + return false; + } + return response.getVersion() != HttpVersion.HTTP_2; + } + + private void markCleartextHttp1Only(URI uri) { + if (!enableHttp1 || !http1OnlyCacheEnabled || http1OnlyCooldownMs <= 0 || uri == null) { + return; + } + if (!"http".equalsIgnoreCase(uri.getScheme())) { + return; + } + markHttp1OnlyOrigin(originKey(uri)); + } + + private ContentResponse tryCleartextHttp11FallbackAfterH2cFailure( + Request request, HTTP2FutureResponseListener listener) + throws InterruptedException, TimeoutException, ExecutionException { + if (!enableHttp1 || request == null) { + return null; + } + URI uri = request.getURI(); + if (uri == null || !"http".equalsIgnoreCase(uri.getScheme()) || !wasH2cUpgradeAttempt(request)) { + return null; + } + if (Boolean.TRUE.equals( + request.getAttributes().get(JmeterHttpClientAttributes.H2C_FALLBACK_ATTEMPTED))) { + return null; + } + lowLevelDebug("Falling back to HTTP/1.1 after failed H2C upgrade for {}", uri); + markCleartextHttp1Only(uri); + return sendWithHTTP11Only(request, listener); + } + private ContentResponse sendWithH2cPriorKnowledge(Request originalRequest) throws InterruptedException, TimeoutException, ExecutionException { URI uri = originalRequest.getURI(); @@ -1254,7 +1327,7 @@ private ContentResponse sendWithH2cPriorKnowledge(Request originalRequest) } } ensureHostHeader(h2cRequest, uri); - configureContentDecoders(httpClientH2cPrior, h2cRequest); + configureContentDecodersAndCapture(httpClientH2cPrior, h2cRequest); if (originalRequest.getBody() != null) { h2cRequest.body(originalRequest.getBody()); } @@ -1287,6 +1360,14 @@ private void samplePrepareRequest(Request request, HTTP2Sampler sampler, HTTPSampleResult result, HttpClient client) throws IOException { + samplePrepareRequest(request, sampler, result, client, false); + } + + private void samplePrepareRequest(Request request, + HTTP2Sampler sampler, + HTTPSampleResult result, + HttpClient client, + boolean areFollowingRedirect) throws IOException { URL url = result.getURL(); lowLevelDebug("Preparing request: URL={}, method={}", url, result.getHTTPMethod()); @@ -1294,12 +1375,16 @@ private void samplePrepareRequest(Request request, request.followRedirects(sampler.getAutoRedirects()); String method = result.getHTTPMethod(); request.method(method); + if (shouldAttachRequestBody(sampler, result, areFollowingRedirect)) { + request.attribute(JmeterHttpClientAttributes.SKIP_H2C_UPGRADE, Boolean.TRUE); + } setHeaders(request, url, sampler.getHeaderManager()); ensureHostHeader(request, url); + ensureEmptyUserAgentHeader(request); addPreemptiveAuthorizationHeader(request, url, sampler.getAuthManager()); lowLevelDebug("Headers set, request URI: {}", request.getURI()); - configureContentDecoders(client, request); + configureContentDecodersAndCapture(client, request); String ae = request.getHeaders() != null ? request.getHeaders().get(HttpHeader.ACCEPT_ENCODING) @@ -1310,6 +1395,14 @@ private void samplePrepareRequest(Request request, CookieManager cookieManager = sampler.getCookieManager(); if (cookieManager != null) { result.setCookies(buildCookies(request, url, cookieManager)); + } else { + HttpFields headers = request.getHeaders(); + if (headers != null) { + String cookieHeader = headers.get(HttpHeader.COOKIE); + if (cookieHeader != null && !cookieHeader.isEmpty()) { + result.setCookies(cookieHeader); + } + } } if (!sampler.getProxyHost().isEmpty()) { @@ -1322,7 +1415,9 @@ private void samplePrepareRequest(Request request, } result.sampleStart(); - setBody(request, sampler, result); + setBody(request, sampler, result, areFollowingRedirect); + setConnectionHeader(request, sampler, url); + reapplyConnectionKeepAliveHeader(request); initializeSentBytes(result, request); } @@ -1348,7 +1443,10 @@ private void postContentResponse(HTTP2Sampler sampler, Request request, JettyCacheManager cacheManager) throws IOException { http1UpgradeRequired = contentResponse.getVersion() != HttpVersion.HTTP_2; - result.setRequestHeaders(getSerializedRequestHeaders(request, true)); + Request effectiveRequest = contentResponse.getRequest() != null + ? contentResponse.getRequest() + : request; + result.setRequestHeaders(getSerializedRequestHeaders(effectiveRequest, true)); setResultContentResponse(result, contentResponse); saveCookiesInCookieManager(contentResponse, request.getURI().toURL(), sampler.getCookieManager()); @@ -1391,12 +1489,17 @@ public HTTPSampleResult sample(HTTP2Sampler sampler, HTTPSampleResult result, lowLevelDebug("=== HTTP2JettyClient.sample() called ==="); lowLevelDebug("Method: {}, URL: {}", result.getHTTPMethod(), result.getURL()); + URL sampleUrl = result.getURL(); + if (sampleUrl != null && "file".equalsIgnoreCase(sampleUrl.getProtocol())) { + return sampleLocalFile(sampler, result, sampleUrl, areFollowingRedirect, depth); + } + errorWhenNotSupportedMethod(result.getHTTPMethod()); setAuthManager(sampler); RequestContext context = buildRequestContext(result, resolveClientForRequest(sampler, result)); Request request = context.request; - samplePrepareRequest(request, sampler, result, context.client); + samplePrepareRequest(request, sampler, result, context.client, areFollowingRedirect); JettyCacheManager cacheManager = JettyCacheManager.fromCacheManager(sampler.getCacheManager()); @@ -1556,12 +1659,21 @@ public ContentResponse send(Request request, HTTP2FutureResponseListener listene if (shouldUseHappyEyeballs(request)) { return sendWithHappyEyeballs(request, listener); } + reapplyConnectionKeepAliveHeader(request); request.send(listener); lowLevelDebug("Request sent, waiting for response..."); try { return getContent(listener, request); } catch (TimeoutException e) { if (http1UpgradeRequired && "http".equalsIgnoreCase(uri.getScheme())) { + try { + ContentResponse fallback = tryCleartextHttp11FallbackAfterH2cFailure(request, listener); + if (fallback != null) { + return fallback; + } + } catch (Exception fallbackException) { + LOG.error("HTTP/1.1 fallback after H2C timeout failed", fallbackException); + } try { LOG.warn("H2C upgrade timed out; retrying with prior knowledge"); return sendWithH2cPriorKnowledge(request); @@ -1816,6 +1928,16 @@ private ContentResponse getContent(HTTP2FutureResponseListener listener, Request response.getStatus(), response.getVersion(), elapsed, contentLength); int headerCount = response.getHeaders() != null ? response.getHeaders().size() : 0; lowLevelDebug("Response headers: {}", headerCount); + if (originalRequest != null && shouldRetryAfterFailedH2cUpgrade(originalRequest, response)) { + lowLevelDebug("H2C upgrade did not negotiate HTTP/2; retrying with HTTP/1.1 for {}", + originalRequest.getURI()); + markCleartextHttp1Only(originalRequest.getURI()); + ContentResponse fallbackResponse = sendWithHTTP11Only(originalRequest, listener); + updateHttp1OnlyCache(originalRequest, fallbackResponse); + updateH2cCache(originalRequest, fallbackResponse); + updateAltSvcCache(originalRequest, fallbackResponse.getHeaders()); + return fallbackResponse; + } if (originalRequest != null && response.getVersion() == HttpVersion.HTTP_3) { recordHttp3Success(originalRequest.getURI()); } @@ -1830,6 +1952,21 @@ private ContentResponse getContent(HTTP2FutureResponseListener listener, Request long endGet = System.currentTimeMillis(); long elapsed = endGet - getStart; LOG.error("Request timeout after {}ms: {}", elapsed, e.getMessage()); + if (originalRequest != null) { + try { + ContentResponse fallback = + tryCleartextHttp11FallbackAfterH2cFailure(originalRequest, listener); + if (fallback != null) { + updateHttp1OnlyCache(originalRequest, fallback); + updateH2cCache(originalRequest, fallback); + updateAltSvcCache(originalRequest, fallback.getHeaders()); + return fallback; + } + } catch (Exception fallbackException) { + LOG.error("HTTP/1.1 fallback after H2C timeout in getContent() failed", + fallbackException); + } + } throw new TimeoutException("The request took more than " + elapsed + " milliseconds to complete"); } catch (ExecutionException e) { @@ -2084,9 +2221,8 @@ private void configureContentDecoders(HttpClient client, Request request) { } else if (addGzip && disableGzipDecoder) { lowLevelDebug("Gzip decoder disabled by blazemeter.http.disableGzipDecoder"); } - if (addDeflate && deflateDecoderFactory != null && !disableDeflateDecoder) { - factories.put(deflateDecoderFactory); - } else if (addDeflate && disableDeflateDecoder) { + // Deflate is decoded in getDecodedContent() to match HttpClient4 (zlib/raw). + if (addDeflate && disableDeflateDecoder) { lowLevelDebug("Deflate decoder disabled by blazemeter.http.disableDeflateDecoder"); } @@ -2105,8 +2241,17 @@ private void configureContentDecoders(HttpClient client, Request request) { } } + private void configureContentDecodersAndCapture(HttpClient client, Request request) { + configureContentDecoders(client, request); + JmeterCompressionHeadersSupport.installCapture(request); + } + private HttpClient selectHttpClient(URI uri) { if (uri != null && "http".equalsIgnoreCase(uri.getScheme())) { + if (enableHttp1 && isHttp1Only(uri)) { + lowLevelDebug("HTTP/1.1-only cache hit for cleartext origin {}", originKey(uri)); + return httpClientHttp1Only; + } if (!enableHttp2 && enableHttp1) { return httpClientHttp1Only; } @@ -2354,21 +2499,30 @@ private void updateHttp1OnlyCache(Request request, Response response) { return; } URI uri = request.getURI(); - if (uri == null || !"https".equalsIgnoreCase(uri.getScheme())) { + if (uri == null) { return; } String origin = originKey(uri); HttpVersion version = response.getVersion(); - if (version == HttpVersion.HTTP_1_1) { - Http1OnlyEntry entry = new Http1OnlyEntry(); - entry.expiresAt = System.currentTimeMillis() + http1OnlyCooldownMs; - HTTP1_ONLY_CACHE.put(origin, entry); - lowLevelDebug("HTTP/1.1-only cache set for origin {} until {}", origin, entry.expiresAt); - } else if (version != null) { - if (HTTP1_ONLY_CACHE.remove(origin) != null) { + if ("https".equalsIgnoreCase(uri.getScheme())) { + if (version == HttpVersion.HTTP_1_1) { + markHttp1OnlyOrigin(origin); + } else if (version != null && HTTP1_ONLY_CACHE.remove(origin) != null) { lowLevelDebug("HTTP/1.1-only cache cleared for origin {}", origin); } + return; } + if ("http".equalsIgnoreCase(uri.getScheme()) && wasH2cUpgradeAttempt(request) + && version != HttpVersion.HTTP_2) { + markHttp1OnlyOrigin(origin); + } + } + + private void markHttp1OnlyOrigin(String origin) { + Http1OnlyEntry entry = new Http1OnlyEntry(); + entry.expiresAt = System.currentTimeMillis() + http1OnlyCooldownMs; + HTTP1_ONLY_CACHE.put(origin, entry); + lowLevelDebug("HTTP/1.1-only cache set for origin {} until {}", origin, entry.expiresAt); } private void updateH2cCache(Request request, Response response) { @@ -2511,7 +2665,7 @@ private Request cloneRequest(Request originalRequest, HttpClient client) if (originalRequest.getBody() != null) { request.body(originalRequest.getBody()); } - configureContentDecoders(client, request); + configureContentDecodersAndCapture(client, request); return request; } @@ -2897,7 +3051,13 @@ private RequestContext buildRequestContext(HTTPSampleResult result, HttpClient c private HttpClient resolveClientForRequest(HTTP2Sampler sampler, HTTPSampleResult result) throws URISyntaxException { - return selectHttpClient(result.getURL().toURI()); + URI uri = result.getURL().toURI(); + if ("http".equalsIgnoreCase(uri.getScheme()) + && shouldAttachRequestBody(sampler, result, false)) { + lowLevelDebug("Cleartext request with body; using HTTP/1.1-only client for {}", uri); + return httpClientHttp1Only; + } + return selectHttpClient(uri); } private boolean requestAdvertisesEncoding(HTTP2Sampler sampler, String encoding) { @@ -2983,8 +3143,10 @@ private void setHeaders(Request request, URL url, HeaderManager headerManager) { // 1. The connection is already HTTP/2 (negotiated via ALPN) // 2. Upgrade headers are for cleartext HTTP, not HTTPS // 3. It violates the HTTP/2 protocol (RFC 7540) - if (http1UpgradeRequired && !"https".equalsIgnoreCase(url.getProtocol()) - && !shouldUseH2cPriorKnowledge(request.getURI())) { + if (http1UpgradeRequired && enableHttp2 && !"https".equalsIgnoreCase(url.getProtocol()) + && !shouldUseH2cPriorKnowledge(request.getURI()) + && !Boolean.TRUE.equals( + request.getAttributes().get(JmeterHttpClientAttributes.SKIP_H2C_UPGRADE))) { Mutable headers = ((Mutable) request.getHeaders()); addHeaderIfMissing(HttpHeader.UPGRADE, "h2c", headers); addHeaderIfMissing(HttpHeader.HTTP2_SETTINGS, buildH2cSettingsHeaderValue(), headers); @@ -3029,6 +3191,55 @@ && shouldUseH2cPriorKnowledge(request.getURI())) { } } + /** + * Matches {@code HTTPHC4Impl#setupRequest}: explicit {@code Connection} header for HTTP/1.1. + */ + private void setConnectionHeader(Request request, HTTP2Sampler sampler, URL url) { + request.attribute(JmeterHttpClientAttributes.USE_KEEPALIVE, sampler.getUseKeepAlive()); + if (!shouldSendConnectionHeader(request, url)) { + return; + } + HttpFields headers = request.getHeaders(); + if (!(headers instanceof HttpFields.Mutable)) { + return; + } + HttpFields.Mutable mutableHeaders = (HttpFields.Mutable) headers; + if (mutableHeaders.contains(HttpHeader.CONNECTION)) { + return; + } + if (sampler.getUseKeepAlive()) { + mutableHeaders.put(HTTPConstants.HEADER_CONNECTION, HTTPConstants.KEEP_ALIVE); + } else { + mutableHeaders.put(HTTPConstants.HEADER_CONNECTION, HTTPConstants.CONNECTION_CLOSE); + } + } + + private void reapplyConnectionKeepAliveHeader(Request request) { + Object useKeepAlive = request.getAttributes().get(JmeterHttpClientAttributes.USE_KEEPALIVE); + if (!Boolean.TRUE.equals(useKeepAlive)) { + return; + } + try { + if (!shouldSendConnectionHeader(request, request.getURI().toURL())) { + return; + } + } catch (MalformedURLException e) { + return; + } + HttpFields headers = request.getHeaders(); + if (headers instanceof HttpFields.Mutable) { + ((HttpFields.Mutable) headers).put(HTTPConstants.HEADER_CONNECTION, HTTPConstants.KEEP_ALIVE); + } + } + + private boolean shouldSendConnectionHeader(Request request, URL url) { + if (request != null && request.getVersion() == HttpVersion.HTTP_2) { + return false; + } + HttpFields headers = request.getHeaders(); + return headers == null || !headers.contains(HttpHeader.CONNECTION); + } + private void ensureHostHeader(Request request, URL url) { if (request == null || url == null) { return; @@ -3047,6 +3258,21 @@ private void ensureHostHeader(Request request, URL url) { mutableHeaders.put(HttpHeader.HOST, hostValue); } + /** + * Matches {@code HTTPHC4Impl}: sends an explicit empty {@code User-Agent} header when none + * is configured, instead of omitting the header entirely. + */ + private void ensureEmptyUserAgentHeader(Request request) { + HttpFields headers = request.getHeaders(); + if (!(headers instanceof HttpFields.Mutable)) { + return; + } + HttpFields.Mutable mutableHeaders = (HttpFields.Mutable) headers; + if (!mutableHeaders.contains(HttpHeader.USER_AGENT)) { + mutableHeaders.put(HttpHeader.USER_AGENT, ""); + } + } + private void ensureHostHeader(Request request, URI uri) { if (request == null || uri == null) { return; @@ -3155,7 +3381,7 @@ private void filterInvalidHTTP2Headers(Request request) { // For HTTPS connections, we assume HTTP/2 if ALPN negotiated it // For HTTP connections, we check if upgrade headers are present boolean isHTTP2 = "https".equalsIgnoreCase(request.getURI().getScheme()) - || (http1UpgradeRequired && headers.contains(HttpHeader.UPGRADE)); + || (enableHttp2 && http1UpgradeRequired && headers.contains(HttpHeader.UPGRADE)); if (isHTTP2) { // HTTP/2 does not support Connection header except for upgrade (which we handle separately) @@ -3271,13 +3497,18 @@ private void addProxyIfEmpty(HttpClient target, String host, int port, boolean s } } - private void setBody(Request request, HTTP2Sampler sampler, HTTPSampleResult result) + private void setBody(Request request, HTTP2Sampler sampler, HTTPSampleResult result, + boolean areFollowingRedirect) throws IOException { + if (!shouldAttachRequestBody(sampler, result, areFollowingRedirect)) { + result.setQueryString(""); + return; + } String contentEncoding = sampler.getContentEncoding(); String contentTypeHeader = request.getHeaders() != null ? request.getHeaders().get(HTTPConstants.HEADER_CONTENT_TYPE) : null; - boolean hasContentTypeHeader = contentTypeHeader != null && contentTypeHeader.isEmpty(); + boolean hasContentTypeHeader = StringUtils.isNotBlank(contentTypeHeader); StringBuilder postBody = new StringBuilder(); if (sampler.getUseMultipart()) { // In Jetty 12, MultiPartRequestContent API has changed significantly @@ -3321,7 +3552,8 @@ private void setBody(Request request, HTTP2Sampler sampler, HTTPSampleResult res if (StringUtils.isBlank(file.getParamName())) { throw new IllegalStateException("Param name is blank"); } - String fileName = Paths.get((file.getPath())).getFileName().toString(); + File resolvedFile = resolveHttpFile(file.getPath()); + String fileName = resolvedFile.getName(); postBody.append(buildFilePartRequestBody(file, fileName, boundary)); } postBody.append(MULTI_PART_SEPARATOR).append(boundary).append(MULTI_PART_SEPARATOR) @@ -3345,55 +3577,154 @@ private void setBody(Request request, HTTP2Sampler sampler, HTTPSampleResult res } } // In Jetty 12, PathRequestContent implements Request.Content directly + File resolvedFile = resolveHttpFile(file.getPath()); Request.Content requestContent = - new PathRequestContent(mimeTypeFile, Path.of(file.getPath())); + new PathRequestContent(mimeTypeFile, resolvedFile.toPath()); request.body(requestContent); postBody.append(""); } else { - if (!hasContentTypeHeader && ADD_CONTENT_TYPE_TO_POST_IF_MISSING) { - HttpFields headers = request.getHeaders(); - if (headers instanceof HttpFields.Mutable) { - ((HttpFields.Mutable) headers).put(HTTPConstants.HEADER_CONTENT_TYPE, - HTTPConstants.APPLICATION_X_WWW_FORM_URLENCODED); - } - } Charset contentCharset = buildCharsetOrDefault(contentEncoding, StandardCharsets.UTF_8); if (sampler.getSendParameterValuesAsPostBody()) { + if (!hasContentTypeHeader) { + HttpFields headers = request.getHeaders(); + if (headers instanceof HttpFields.Mutable) { + ((HttpFields.Mutable) headers).put(HTTPConstants.HEADER_CONTENT_TYPE, + "text/plain; charset=" + contentCharset.name()); + } + } for (JMeterProperty jMeterProperty : sampler.getArguments()) { HTTPArgument arg = (HTTPArgument) jMeterProperty.getObjectValue(); postBody.append(arg.getEncodedValue(contentCharset.name())); } - // In Jetty 12, StringRequestContent implements Request.Content directly + String bodyContentType = request.getHeaders() != null + ? request.getHeaders().get(HTTPConstants.HEADER_CONTENT_TYPE) + : null; Request.Content requestContent = - new StringRequestContent(contentTypeHeader, postBody.toString(), - contentCharset); + new StringRequestContent(bodyContentType, postBody.toString(), contentCharset); request.body(requestContent); - } else if (isMethodWithBody(sampler.getMethod())) { - Fields fields = new Fields(); - for (JMeterProperty p : sampler.getArguments()) { - HTTPArgument arg = (HTTPArgument) p.getObjectValue(); - String parameterName = arg.getName(); - if (!arg.isSkippable(parameterName)) { - String parameterValue = arg.getValue(); - if (!arg.isAlwaysEncoded()) { - // The FormRequestContent always urlencodes both name and value, in this case the - // value is already encoded by the user so is needed to decode the value now, so - // that when the httpclient encodes it, we end up with the same value as the user - // had entered. - parameterName = URLDecoder.decode(parameterName, contentCharset.name()); - parameterValue = URLDecoder.decode(parameterValue, contentCharset.name()); + } else { + if (!hasContentTypeHeader && ADD_CONTENT_TYPE_TO_POST_IF_MISSING + && isMethodWithBody(sampler.getMethod())) { + HttpFields headers = request.getHeaders(); + if (headers instanceof HttpFields.Mutable) { + ((HttpFields.Mutable) headers).put(HTTPConstants.HEADER_CONTENT_TYPE, + HTTPConstants.APPLICATION_X_WWW_FORM_URLENCODED); + } + } + if (isMethodWithBody(sampler.getMethod())) { + Fields fields = new Fields(); + for (JMeterProperty p : sampler.getArguments()) { + HTTPArgument arg = (HTTPArgument) p.getObjectValue(); + String parameterName = arg.getName(); + if (!arg.isSkippable(parameterName)) { + String parameterValue = arg.getValue(); + if (!arg.isAlwaysEncoded()) { + // The FormRequestContent always urlencodes both name and value, in this case the + // value is already encoded by the user so is needed to decode the value now, so + // that when the httpclient encodes it, we end up with the same value as the user + // had entered. + parameterName = URLDecoder.decode(parameterName, contentCharset.name()); + parameterValue = URLDecoder.decode(parameterValue, contentCharset.name()); + } + fields.add(parameterName, parameterValue); } - fields.add(parameterName, parameterValue); } + postBody.append(FormRequestContent.convert(fields)); + request.body(new FormRequestContent(fields, contentCharset)); } - postBody.append(FormRequestContent.convert(fields)); - request.body(new FormRequestContent(fields, contentCharset)); } } } result.setQueryString(postBody.toString()); } + private File resolveHttpFile(String path) throws IOException { + if (StringUtils.isBlank(path)) { + throw new IOException("Empty HTTP file path"); + } + File resolved = FileServer.getFileServer().getResolvedFile(path); + if (resolved.isFile()) { + return resolved; + } + Path inBin = Paths.get(JMeterUtils.getJMeterBinDir(), path); + if (Files.isRegularFile(inBin)) { + return inBin.toFile(); + } + throw new IOException("HTTP file not found: " + path); + } + + private HTTPSampleResult sampleLocalFile(HTTP2Sampler sampler, HTTPSampleResult result, URL url, + boolean areFollowingRedirect, int depth) + throws Exception { + Path filePath = resolveLocalFilePath(url); + byte[] data = Files.readAllBytes(filePath); + result.sampleStart(); + result.setResponseCode("200"); + result.setResponseMessage("OK"); + result.setSuccessful(true); + result.setResponseData(data); + String encoding = sampler.getContentEncoding(); + if (StringUtils.isNotBlank(encoding)) { + result.setDataEncoding(encoding); + } + String contentType = probeLocalFileContentType(filePath); + if (contentType != null) { + result.setContentType(contentType); + result.setResponseHeaders("Content-Type: " + contentType + "\n"); + } + if (contentType != null && contentType.toLowerCase(Locale.ROOT).startsWith("text/")) { + result.setDataType(SampleResult.TEXT); + } + result.sampleEnd(); + resetSamplerDataBeforeResultProcessing(result); + return sampler.resultProcessing(areFollowingRedirect, depth, result); + } + + private static Path resolveLocalFilePath(URL url) throws IOException { + try { + Path direct = Paths.get(url.toURI()); + if (Files.isRegularFile(direct)) { + return direct; + } + } catch (Exception ignored) { + // Fall back to JMeter bin-relative paths used by bin/testfiles JMX plans. + } + String rawPath = url.getPath(); + if (rawPath == null || rawPath.isEmpty()) { + throw new IOException("Empty file URL path: " + url); + } + String relative = rawPath.startsWith("/") ? rawPath.substring(1) : rawPath; + Path inBin = Paths.get(JMeterUtils.getJMeterBinDir()).resolve(relative); + if (Files.isRegularFile(inBin)) { + return inBin; + } + throw new FileNotFoundException(relative + " (The system cannot find the file specified)"); + } + + private static String probeLocalFileContentType(Path filePath) throws IOException { + String probed = Files.probeContentType(filePath); + if (StringUtils.isNotBlank(probed)) { + return probed; + } + String name = filePath.getFileName().toString().toLowerCase(Locale.ROOT); + if (name.endsWith(".html") || name.endsWith(".htm")) { + return "text/html"; + } + if (name.endsWith(".css")) { + return "text/css"; + } + if (name.endsWith(".gif")) { + return "image/gif"; + } + if (name.endsWith(".jpg") || name.endsWith(".jpeg")) { + return "image/jpeg"; + } + if (name.endsWith(".png")) { + return "image/png"; + } + return null; + } + private void initializeSentBytes(HTTPSampleResult result, Request request) { if (result.getSentBytes() > 0) { return; @@ -3451,6 +3782,11 @@ private Charset buildCharsetOrDefault(String contentEncoding, Charset defaultCha return !contentEncoding.isEmpty() ? Charset.forName(contentEncoding) : defaultCharset; } + /** HttpClient4 writes raw argument values in multipart parts (not URL-encoded). */ + private static String multipartArgumentValue(HTTPArgument arg) { + return arg.getValue(); + } + private String buildArgumentPartRequestBody(HTTPArgument arg, Charset contentCharset, String contentEncoding, String boundary) throws UnsupportedEncodingException { @@ -3458,20 +3794,29 @@ private String buildArgumentPartRequestBody(HTTPArgument arg, Charset contentCha String contentType = arg.getContentType() + "; charset=" + contentCharset.name(); String encoding = StringUtils.isNotBlank(contentEncoding) ? contentEncoding : "8bit"; return buildPartBody(boundary, disposition, contentType, encoding, - arg.getEncodedValue(contentCharset.name())); + multipartArgumentValue(arg)); } private String buildPartBody(String boundary, String disposition, String contentType, String encoding, String value) { - return MULTI_PART_SEPARATOR + boundary + LINE_SEPARATOR + - HttpFields.build() - .add("Content-Disposition", "form-data; " + disposition) - .add(HttpHeader.CONTENT_TYPE.toString(), contentType) - .add("Content-Transfer-Encoding", encoding) - .toString() + return MULTI_PART_SEPARATOR + boundary + LINE_SEPARATOR + + formatMultipartPartHeaders("form-data; " + disposition, contentType, encoding) + value + LINE_SEPARATOR; } + /** HttpClient4 uses canonical header names; Jetty {@link HttpFields#toString()} lowercases them. */ + private String formatMultipartPartHeaders(String disposition, String contentType, + String transferEncoding) { + StringBuilder headers = new StringBuilder(); + headers.append("Content-Disposition: ").append(disposition).append(LINE_SEPARATOR); + headers.append("Content-Type: ").append(contentType).append(LINE_SEPARATOR); + if (transferEncoding != null && !transferEncoding.isEmpty()) { + headers.append("Content-Transfer-Encoding: ").append(transferEncoding) + .append(LINE_SEPARATOR); + } + return headers.toString(); + } + private String buildFilePartRequestBody(HTTPFileArg file, String fileName, String boundary) { String disposition = "name=\"" + file.getParamName() + "\"; filename=\"" + fileName + "\""; return buildPartBody(boundary, disposition, file.getMimeType(), "binary", @@ -3520,14 +3865,12 @@ private byte[] buildMultipartBodyBytes(HTTP2Sampler sampler, String boundary, argContentType = argContentType + "; charset=" + contentCharset.name().toLowerCase(Locale.ROOT); - Mutable partHeaders = HttpFields.build() - .add("Content-Disposition", "form-data; name=\"" + arg.getEncodedName() + "\"") - .add(HttpHeader.CONTENT_TYPE, argContentType); + String partHeaders = formatMultipartPartHeaders( + "form-data; name=\"" + arg.getEncodedName() + "\"", argContentType, "8bit"); output.write(boundaryLine.getBytes(StandardCharsets.US_ASCII)); - output.write(partHeaders.toString().getBytes(StandardCharsets.US_ASCII)); - output.write(newLine.getBytes(StandardCharsets.US_ASCII)); - String argValue = arg.getEncodedValue(contentCharset.name()); + output.write(partHeaders.getBytes(StandardCharsets.US_ASCII)); + String argValue = multipartArgumentValue(arg); output.write(argValue.getBytes(contentCharset)); output.write(newLine.getBytes(StandardCharsets.US_ASCII)); } @@ -3538,22 +3881,21 @@ private byte[] buildMultipartBodyBytes(HTTP2Sampler sampler, String boundary, if (StringUtils.isBlank(file.getParamName())) { throw new IllegalStateException("Param name is blank"); } - String fileName = Paths.get(file.getPath()).getFileName().toString(); + File resolvedFile = resolveHttpFile(file.getPath()); + String fileName = resolvedFile.getName(); String mimeTypeFile = extractFileMimeType(hasContentTypeHeader, file); // Build headers using HttpFields to match the format expected by tests // The test uses HttpFields.build().toString() which has a specific format - Mutable partHeaders = HttpFields.build() - .add("Content-Disposition", - "form-data; name=\"" + file.getParamName() + "\"; filename=\"" + fileName + "\"") - .add(HttpHeader.CONTENT_TYPE, mimeTypeFile); + String partHeaders = formatMultipartPartHeaders( + "form-data; name=\"" + file.getParamName() + "\"; filename=\"" + fileName + "\"", + mimeTypeFile, "binary"); output.write(boundaryLine.getBytes(StandardCharsets.US_ASCII)); - output.write(partHeaders.toString().getBytes(StandardCharsets.US_ASCII)); - output.write(newLine.getBytes(StandardCharsets.US_ASCII)); + output.write(partHeaders.getBytes(StandardCharsets.US_ASCII)); // Read and write file content - try (InputStream fileStream = Files.newInputStream(Paths.get(file.getPath()))) { + try (InputStream fileStream = Files.newInputStream(resolvedFile.toPath())) { byte[] buffer = new byte[8192]; int bytesRead; while ((bytesRead = fileStream.read(buffer)) != -1) { @@ -3574,6 +3916,29 @@ private boolean isMethodWithBody(String method) { return METHODS_WITH_BODY.contains(method); } + /** + * Matches HttpClient4: entities are only attached for POST/PUT/PATCH, or GET/DELETE when + * {@code postBodyRaw} is enabled ({@code HttpGetWithEntity}). + */ + private boolean shouldAttachRequestBody(HTTP2Sampler sampler, HTTPSampleResult result, + boolean areFollowingRedirect) { + String method = resolveRequestMethod(sampler, result); + if (areFollowingRedirect && !isMethodWithBody(method)) { + return false; + } + if (isMethodWithBody(method)) { + return true; + } + return sampler.getSendParameterValuesAsPostBody(); + } + + private String resolveRequestMethod(HTTP2Sampler sampler, HTTPSampleResult result) { + if (result != null && StringUtils.isNotBlank(result.getHTTPMethod())) { + return result.getHTTPMethod(); + } + return sampler.getMethod(); + } + private boolean isSupportedMethod(String method) { return SUPPORTED_METHODS.contains(method); } @@ -3584,6 +3949,9 @@ private String buildHeadersString(HttpFields headers) { } else { String ret = HttpFields.build(headers).remove(HTTPConstants.HEADER_COOKIE).toString() .replace("\r\n", "\n"); + if (ret.isEmpty()) { + return ""; + } return ret.substring(0, ret.length() - 1); // removing final separator not included in jmeter headers } @@ -3623,8 +3991,7 @@ private void setResultContentResponse(HTTPSampleResult result, } result.setResponseCode(String.valueOf(contentResponse.getStatus())); - String responseMessage = contentResponse.getReason() != null ? contentResponse.getReason() - : HttpStatus.getMessage(contentResponse.getStatus()); + String responseMessage = resolveJmeterResponseMessage(contentResponse); result.setResponseMessage(responseMessage); result.setSuccessful( contentResponse.getStatus() >= 200 && contentResponse.getStatus() <= 399); @@ -3637,9 +4004,11 @@ private void setResultContentResponse(HTTPSampleResult result, result.setURL(contentResponse.getRequest().getURI().toURL()); } + HttpFields sampleResultHeaders = + JmeterCompressionHeadersSupport.headersForSampleResult(contentResponse); long headerBytes = (long) result.getResponseHeaders().length() // condensed length (without \r) - + (long) contentResponse.getHeaders().asString().length() // Add \r for each header + + (long) sampleResultHeaders.asString().length() // Add \r for each header + 1L // Add \r for initial header + 2L; // final \r\n before data result.setHeadersSize((int) headerBytes); @@ -3647,8 +4016,18 @@ private void setResultContentResponse(HTTPSampleResult result, private String extractResponseHeaders(ContentResponse contentResponse, String message) { + HttpFields headers = JmeterCompressionHeadersSupport.headersForSampleResult(contentResponse); return contentResponse.getVersion() + " " + contentResponse.getStatus() + " " + message + "\n" - + buildHeadersString(contentResponse.getHeaders()); + + buildHeadersString(headers); + } + + private String resolveJmeterResponseMessage(ContentResponse contentResponse) { + int status = contentResponse.getStatus(); + String reason = contentResponse.getReason(); + if (reason != null && !reason.isEmpty()) { + return reason; + } + return HttpStatus.getMessage(status); } private String extractRedirectLocation(ContentResponse contentResponse) { @@ -3738,6 +4117,8 @@ private byte[] maybeDecodeCompressedContent(ContentResponse contentResponse) { if (contentEncoding == null || contentEncoding.trim().isEmpty()) { return content; } + JmeterCompressionHeadersSupport.captureIfCompressed( + contentResponse.getRequest(), contentResponse.getHeaders()); String encodingToken = normalizeEncodingToken(contentEncoding); if (encodingToken.isEmpty()) { return content; @@ -3748,6 +4129,7 @@ private byte[] maybeDecodeCompressedContent(ContentResponse contentResponse) { boolean skipRedundantManualDecode = Boolean.parseBoolean( System.getProperty(PROP_SKIP_REDUNDANT_MANUAL_DECODE, "true")); if (skipRedundantManualDecode + && !"deflate".equals(encodingToken) && requestAdvertisedEncoding(contentResponse.getRequest(), encodingToken)) { return content; } @@ -3820,14 +4202,27 @@ private byte[] decodeGzip(byte[] content, String contentEncoding) { } private byte[] decodeDeflate(byte[] content, String contentEncoding) { - try (InputStream input = new InflaterInputStream(new ByteArrayInputStream(content)); + byte[] zlibDecoded = tryInflateDeflate(content, false); + if (zlibDecoded != null) { + return zlibDecoded; + } + byte[] rawDecoded = tryInflateDeflate(content, true); + if (rawDecoded != null) { + return rawDecoded; + } + lowLevelDebug("Failed to decode deflate content ({}), keeping original bytes", + contentEncoding); + return content; + } + + private byte[] tryInflateDeflate(byte[] content, boolean nowrap) { + try (InflaterInputStream input = new InflaterInputStream( + new ByteArrayInputStream(content), new Inflater(nowrap)); ByteArrayOutputStream output = new ByteArrayOutputStream(content.length)) { copy(input, output); return output.toByteArray(); } catch (IOException e) { - lowLevelDebug("Failed to decode deflate content ({}), keeping original bytes", - contentEncoding, e); - return content; + return null; } } diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactory.java b/src/main/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactory.java index 10aa005..ddafb7e 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactory.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactory.java @@ -16,6 +16,11 @@ import org.apache.jmeter.util.keystore.JmeterKeyStore; import org.eclipse.jetty.util.ssl.SslContextFactory; +/** + * Jetty SSL client wired to JMeter {@link SSLManager}. + * Keystore/truststore path resolution improvements are tracked in + * PR #112. + */ public class JMeterJettySslContextFactory extends SslContextFactory.Client { private final JmeterKeyStore keys; diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/JmeterCompressionHeadersSupport.java b/src/main/java/com/blazemeter/jmeter/http2/core/JmeterCompressionHeadersSupport.java new file mode 100644 index 0000000..b936947 --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/core/JmeterCompressionHeadersSupport.java @@ -0,0 +1,144 @@ +package com.blazemeter.jmeter.http2.core; + +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.eclipse.jetty.client.ContentResponse; +import org.eclipse.jetty.client.Request; +import org.eclipse.jetty.client.Response; +import org.eclipse.jetty.http.HttpField; +import org.eclipse.jetty.http.HttpFields; +import org.eclipse.jetty.http.HttpHeader; + +/** + * Preserves wire {@code Content-Encoding}, {@code Content-Length} and {@code Content-MD5} + * for compressed responses, matching Apache JMeter {@code HTTPHC4Impl} behaviour after + * HttpClient {@code ResponseContentEncoding} decodes the body (Bug 59401). + */ +final class JmeterCompressionHeadersSupport { + + static final String REQUEST_ATTR_WIRE_COMPRESSION_HEADERS = + "bzm.jmeter.wireCompressionHeaders"; + + private static final String[] HEADERS_TO_SAVE = { + HTTPConstants.HEADER_CONTENT_LENGTH, + HTTPConstants.HEADER_CONTENT_ENCODING, + "Content-MD5" + }; + + private JmeterCompressionHeadersSupport() { + } + + static void installCapture(Request request) { + if (request == null) { + return; + } + request.onResponseHeader(JmeterCompressionHeadersSupport::onResponseHeader); + request.onResponseHeaders(JmeterCompressionHeadersSupport::onResponseHeaders); + } + + private static boolean onResponseHeader(Response response, HttpField field) { + if (response != null && field != null && HttpHeader.CONTENT_ENCODING.is(field.getName())) { + Request request = response.getRequest(); + if (request != null) { + mergeSavedHeader(request, field.getName(), field.getValue()); + } + } + return true; + } + + private static void onResponseHeaders(Response response) { + if (response != null && response.getRequest() != null && response.getHeaders() != null) { + captureIfCompressed(response.getRequest(), response.getHeaders()); + } + } + + private static void mergeSavedHeader(Request request, String name, String value) { + if (request == null || name == null || value == null) { + return; + } + HttpFields.Mutable saved = getOrCreateSaved(request); + saved.put(name, value); + request.attribute(REQUEST_ATTR_WIRE_COMPRESSION_HEADERS, saved); + } + + private static HttpFields.Mutable getOrCreateSaved(Request request) { + Object existing = request.getAttributes().get(REQUEST_ATTR_WIRE_COMPRESSION_HEADERS); + if (existing instanceof HttpFields.Mutable) { + return (HttpFields.Mutable) existing; + } + if (existing instanceof HttpFields) { + HttpFields.Mutable copy = HttpFields.build((HttpFields) existing); + request.attribute(REQUEST_ATTR_WIRE_COMPRESSION_HEADERS, copy); + return copy; + } + return HttpFields.build(); + } + + static void captureIfCompressed(Request request, HttpFields headers) { + if (request == null || headers == null) { + return; + } + String contentEncoding = headers.get(HttpHeader.CONTENT_ENCODING); + if (contentEncoding == null || contentEncoding.trim().isEmpty()) { + return; + } + HttpFields.Mutable saved = getOrCreateSaved(request); + for (String name : HEADERS_TO_SAVE) { + String value = headers.get(name); + if (value != null) { + saved.put(name, value); + } + } + if (saved.size() > 0) { + request.attribute(REQUEST_ATTR_WIRE_COMPRESSION_HEADERS, saved); + } + } + + static HttpFields headersForSampleResult(ContentResponse contentResponse) { + if (contentResponse == null || contentResponse.getHeaders() == null) { + return contentResponse != null ? contentResponse.getHeaders() : HttpFields.EMPTY; + } + HttpFields current = contentResponse.getHeaders(); + Request request = contentResponse.getRequest(); + if (request == null) { + return current; + } + Object savedAttr = request.getAttributes().get(REQUEST_ATTR_WIRE_COMPRESSION_HEADERS); + if (!(savedAttr instanceof HttpFields)) { + return current; + } + HttpFields saved = (HttpFields) savedAttr; + if (saved.size() == 0) { + return current; + } + HttpFields.Mutable merged = HttpFields.build(current); + // Jetty removes Content-Encoding but often leaves an updated Content-Length (decoded size). + // Match HTTPHC4Impl: restore all wire compression headers when encoding was stripped. + if (!hasContentEncoding(current) && hasContentEncoding(saved)) { + for (String name : HEADERS_TO_SAVE) { + String value = saved.get(name); + if (value != null) { + merged.put(name, value); + } + } + } else { + for (String name : HEADERS_TO_SAVE) { + if (merged.contains(name)) { + continue; + } + String value = saved.get(name); + if (value != null) { + merged.put(name, value); + } + } + } + return merged; + } + + private static boolean hasContentEncoding(HttpFields headers) { + if (headers == null) { + return false; + } + String contentEncoding = headers.get(HttpHeader.CONTENT_ENCODING); + return contentEncoding != null && !contentEncoding.trim().isEmpty(); + } +} diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/JmeterHttpClientAttributes.java b/src/main/java/com/blazemeter/jmeter/http2/core/JmeterHttpClientAttributes.java new file mode 100644 index 0000000..12bbf8d --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/core/JmeterHttpClientAttributes.java @@ -0,0 +1,14 @@ +package com.blazemeter.jmeter.http2.core; + +/** + * Request attributes shared between the Jetty client layer and JMeter parity hooks. + */ +public final class JmeterHttpClientAttributes { + + public static final String USE_KEEPALIVE = "bzm.useKeepAlive"; + public static final String H2C_FALLBACK_ATTEMPTED = "bzm.h2cFallbackAttempted"; + public static final String SKIP_H2C_UPGRADE = "bzm.skipH2cUpgrade"; + + private JmeterHttpClientAttributes() { + } +} diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/JmeterHttpClientExceptionMapper.java b/src/main/java/com/blazemeter/jmeter/http2/core/JmeterHttpClientExceptionMapper.java new file mode 100644 index 0000000..c5dd3bf --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/core/JmeterHttpClientExceptionMapper.java @@ -0,0 +1,105 @@ +package com.blazemeter.jmeter.http2.core; + +import java.io.IOException; +import java.net.ConnectException; +import java.net.InetAddress; +import java.net.URL; +import java.net.UnknownHostException; +import java.util.Locale; +import java.util.concurrent.ExecutionException; +import org.apache.http.HttpHost; +import org.apache.http.client.ClientProtocolException; +import org.apache.http.conn.HttpHostConnectException; + +/** + * Maps Jetty client failures to the exception types JMeter {@code HTTPHC4Impl} reports in + * {@code HTTPSampleResult} for semantic parity. + */ +public final class JmeterHttpClientExceptionMapper { + + private JmeterHttpClientExceptionMapper() { + } + + public static Throwable forSampleResult(Throwable thrown, boolean autoRedirects) { + return forSampleResult(thrown, autoRedirects, null); + } + + public static Throwable forSampleResult(Throwable thrown, boolean autoRedirects, URL url) { + if (thrown == null) { + return null; + } + Throwable root = unwrap(thrown); + if (autoRedirects && isAutoRedirectProtocolFailure(root)) { + return new ClientProtocolException(); + } + Throwable connectMapped = mapConnectFailure(root, url); + if (connectMapped != null) { + return connectMapped; + } + return thrown; + } + + private static Throwable unwrap(Throwable throwable) { + Throwable current = throwable; + while (current instanceof ExecutionException && current.getCause() != null) { + current = current.getCause(); + } + return current; + } + + private static boolean isAutoRedirectProtocolFailure(Throwable throwable) { + if (throwable == null) { + return false; + } + if (!"HttpResponseException".equals(throwable.getClass().getSimpleName())) { + return false; + } + String message = throwable.getMessage(); + return message != null && message.contains("Max redirects exceeded"); + } + + private static Throwable mapConnectFailure(Throwable root, URL url) { + if (root instanceof HttpHostConnectException) { + return root; + } + if (root instanceof ConnectException) { + HttpHost host = toHttpHost(url); + ConnectException cause = normalizeConnectCause((ConnectException) root); + if (host != null) { + try { + return new HttpHostConnectException(cause, host, + InetAddress.getAllByName(host.getHostName())); + } catch (UnknownHostException ignored) { + // Fall back to host-only message formatting. + } + } + return new HttpHostConnectException(cause, host); + } + return null; + } + + private static ConnectException normalizeConnectCause(ConnectException root) { + String message = root.getMessage(); + if (message != null && message.toLowerCase(Locale.ROOT).contains("getsockopt")) { + ConnectException normalized = new ConnectException("Connection refused: connect"); + normalized.initCause(root); + return normalized; + } + return root; + } + + private static HttpHost toHttpHost(URL url) { + if (url == null || url.getHost() == null || url.getHost().isEmpty()) { + return null; + } + int port = url.getPort(); + if (port < 0) { + port = url.getDefaultPort(); + } + String scheme = url.getProtocol(); + if (scheme == null || scheme.isEmpty()) { + return new HttpHost(url.getHost(), port); + } + return new HttpHost(url.getHost(), port, scheme); + } +} diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/CustomHttpChannelOverHTTP.java b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/CustomHttpChannelOverHTTP.java new file mode 100644 index 0000000..4dcd7de --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/CustomHttpChannelOverHTTP.java @@ -0,0 +1,20 @@ +package com.blazemeter.jmeter.http2.core.jetty.custom.http1; + +import org.eclipse.jetty.client.transport.internal.HttpChannelOverHTTP; +import org.eclipse.jetty.client.transport.internal.HttpConnectionOverHTTP; +import org.eclipse.jetty.client.transport.internal.HttpSenderOverHTTP; + +/** + * HTTP/1 channel that injects the custom sender. + */ +public class CustomHttpChannelOverHTTP extends HttpChannelOverHTTP { + + public CustomHttpChannelOverHTTP(HttpConnectionOverHTTP connection) { + super(connection); + } + + @Override + protected HttpSenderOverHTTP newHttpSender() { + return new CustomHttpSenderOverHTTP(this); + } +} diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/CustomHttpClientConnectionFactory.java b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/CustomHttpClientConnectionFactory.java new file mode 100644 index 0000000..2d4fdf0 --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/CustomHttpClientConnectionFactory.java @@ -0,0 +1,47 @@ +package com.blazemeter.jmeter.http2.core.jetty.custom.http1; + +import java.util.List; +import java.util.Map; +import org.eclipse.jetty.client.transport.HttpClientConnectionFactory; +import org.eclipse.jetty.client.transport.internal.HttpConnectionOverHTTP; +import org.eclipse.jetty.io.ClientConnectionFactory; +import org.eclipse.jetty.io.Connection; +import org.eclipse.jetty.io.EndPoint; + +/** + * HTTP/1 connection factory wired for JMeter HttpClient4 header parity. + */ +public class CustomHttpClientConnectionFactory extends HttpClientConnectionFactory { + + /** + * Custom HTTP/1.1 factory for JMeter header parity. Use this instead of the inherited + * {@link HttpClientConnectionFactory#HTTP11} static field (same simple name as the nested class). + */ + public static final ClientConnectionFactory.Info CUSTOM_HTTP11 = new HTTP11(); + + @Override + public Connection newConnection(EndPoint endPoint, Map context) { + HttpConnectionOverHTTP connection = new CustomHttpConnectionOverHTTP(endPoint, context); + connection.setInitialize(isInitializeConnections()); + return customize(connection, context); + } + + public static class HTTP11 extends ClientConnectionFactory.Info { + + private final List protocols; + + public HTTP11() { + this(List.of("http/1.1")); + } + + public HTTP11(List protocols) { + super(new CustomHttpClientConnectionFactory()); + this.protocols = protocols; + } + + @Override + public List getProtocols(boolean secure) { + return protocols; + } + } +} diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/CustomHttpConnectionOverHTTP.java b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/CustomHttpConnectionOverHTTP.java new file mode 100644 index 0000000..40a2edc --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/CustomHttpConnectionOverHTTP.java @@ -0,0 +1,21 @@ +package com.blazemeter.jmeter.http2.core.jetty.custom.http1; + +import org.eclipse.jetty.client.transport.internal.HttpChannelOverHTTP; +import org.eclipse.jetty.client.transport.internal.HttpConnectionOverHTTP; +import org.eclipse.jetty.io.EndPoint; + +/** + * HTTP/1 connection that creates custom channels over a parity-aware endpoint. + */ +public class CustomHttpConnectionOverHTTP extends HttpConnectionOverHTTP { + + public CustomHttpConnectionOverHTTP(EndPoint endPoint, java.util.Map context) { + super(endPoint instanceof KeepAliveParityEndPoint ? endPoint + : new KeepAliveParityEndPoint(endPoint), context); + } + + @Override + protected HttpChannelOverHTTP newHttpChannel() { + return new CustomHttpChannelOverHTTP(this); + } +} diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/CustomHttpSenderOverHTTP.java b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/CustomHttpSenderOverHTTP.java new file mode 100644 index 0000000..3f328e0 --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/CustomHttpSenderOverHTTP.java @@ -0,0 +1,108 @@ +package com.blazemeter.jmeter.http2.core.jetty.custom.http1; + +import com.blazemeter.jmeter.http2.core.JmeterHttpClientAttributes; +import java.nio.ByteBuffer; +import java.util.Locale; +import org.eclipse.jetty.client.transport.HttpExchange; +import org.eclipse.jetty.client.transport.HttpRequest; +import org.eclipse.jetty.client.transport.internal.HttpChannelOverHTTP; +import org.eclipse.jetty.client.transport.internal.HttpConnectionOverHTTP; +import org.eclipse.jetty.client.transport.internal.HttpSenderOverHTTP; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpVersion; +import org.eclipse.jetty.io.EndPoint; +import org.eclipse.jetty.util.Callback; + +/** + * HTTP/1 sender that preserves explicit {@code Connection: keep-alive} like Apache HttpClient4. + */ +public class CustomHttpSenderOverHTTP extends HttpSenderOverHTTP { + + public CustomHttpSenderOverHTTP(HttpChannelOverHTTP channel) { + super(channel); + } + + @Override + protected void sendHeaders(HttpExchange exchange, ByteBuffer contentBuffer, boolean lastContent, + Callback callback) { + HttpRequest request = exchange.getRequest(); + if (!shouldEmitExplicitKeepAlive(request)) { + super.sendHeaders(exchange, contentBuffer, lastContent, callback); + return; + } + + HttpVersion savedVersion = request.getVersion(); + KeepAliveParityEndPoint parityEndPoint = resolveParityEndPoint(); + if (parityEndPoint != null) { + parityEndPoint.enableRequestLinePatch(); + } + request.version(HttpVersion.HTTP_1_0); + super.sendHeaders(exchange, contentBuffer, lastContent, new Callback() { + @Override + public void succeeded() { + try { + callback.succeeded(); + } finally { + request.version(savedVersion); + if (parityEndPoint != null) { + parityEndPoint.disableRequestLinePatch(); + } + } + } + + @Override + public void failed(Throwable x) { + try { + callback.failed(x); + } finally { + request.version(savedVersion); + if (parityEndPoint != null) { + parityEndPoint.disableRequestLinePatch(); + } + } + } + }); + } + + private KeepAliveParityEndPoint resolveParityEndPoint() { + HttpConnectionOverHTTP connection = getHttpChannel().getHttpConnection(); + EndPoint endPoint = connection.getEndPoint(); + if (endPoint instanceof KeepAliveParityEndPoint) { + return (KeepAliveParityEndPoint) endPoint; + } + if (endPoint instanceof EndPoint.Wrapper) { + EndPoint unwrapped = ((EndPoint.Wrapper) endPoint).unwrap(); + if (unwrapped instanceof KeepAliveParityEndPoint) { + return (KeepAliveParityEndPoint) unwrapped; + } + } + return null; + } + + private boolean shouldEmitExplicitKeepAlive(HttpRequest request) { + if (hasBlockingConnectionToken(request)) { + return false; + } + Object useKeepAlive = request.getAttributes().get(JmeterHttpClientAttributes.USE_KEEPALIVE); + if (Boolean.TRUE.equals(useKeepAlive)) { + return request.getVersion() == HttpVersion.HTTP_1_1; + } + String connection = request.getHeaders().get(HttpHeader.CONNECTION); + return connection != null + && connection.toLowerCase(Locale.ROOT).contains("keep-alive"); + } + + private boolean hasBlockingConnectionToken(HttpRequest request) { + String connection = request.getHeaders().get(HttpHeader.CONNECTION); + if (connection == null) { + return false; + } + for (String token : connection.split(",")) { + String trimmed = token.trim(); + if ("Upgrade".equalsIgnoreCase(trimmed) || "close".equalsIgnoreCase(trimmed)) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/KeepAliveParityEndPoint.java b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/KeepAliveParityEndPoint.java new file mode 100644 index 0000000..3e93bbd --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/KeepAliveParityEndPoint.java @@ -0,0 +1,226 @@ +package com.blazemeter.jmeter.http2.core.jetty.custom.http1; + +import java.io.IOException; +import java.net.SocketAddress; +import java.nio.ByteBuffer; +import java.nio.channels.ReadPendingException; +import java.nio.channels.WritePendingException; +import org.eclipse.jetty.io.Connection; +import org.eclipse.jetty.io.EndPoint; +import org.eclipse.jetty.util.Callback; + +/** + * Patches the HTTP request line on the wire from {@code HTTP/1.0} to {@code HTTP/1.1}. + * + *

Jetty's {@code HttpGenerator} strips explicit {@code Connection: keep-alive} for HTTP/1.1 + * but keeps it for HTTP/1.0. The custom HTTP/1 sender temporarily uses HTTP/1.0 metadata and + * this endpoint rewrites only the request line so JMeter mirror assertions still see HTTP/1.1. + */ +public class KeepAliveParityEndPoint implements EndPoint, EndPoint.Wrapper { + + private final EndPoint delegate; + private volatile boolean patchRequestLineToHttp11; + + public KeepAliveParityEndPoint(EndPoint delegate) { + this.delegate = delegate; + } + + public void enableRequestLinePatch() { + patchRequestLineToHttp11 = true; + } + + public void disableRequestLinePatch() { + patchRequestLineToHttp11 = false; + } + + @Override + public EndPoint unwrap() { + return delegate; + } + + @Override + public SocketAddress getLocalSocketAddress() { + return delegate.getLocalSocketAddress(); + } + + @Override + public SocketAddress getRemoteSocketAddress() { + return delegate.getRemoteSocketAddress(); + } + + @Override + public int fill(ByteBuffer buffer) throws IOException { + return delegate.fill(buffer); + } + + @Override + public SocketAddress receive(ByteBuffer buffer) throws IOException { + return delegate.receive(buffer); + } + + @Override + public boolean flush(ByteBuffer... buffers) throws IOException { + return delegate.flush(buffers); + } + + @Override + public boolean send(SocketAddress address, ByteBuffer... buffers) throws IOException { + return delegate.send(address, buffers); + } + + @Override + public boolean isSecure() { + return delegate.isSecure(); + } + + @Override + public SslSessionData getSslSessionData() { + return delegate.getSslSessionData(); + } + + @Override + public boolean isOpen() { + return delegate.isOpen(); + } + + @Override + public long getCreatedTimeStamp() { + return delegate.getCreatedTimeStamp(); + } + + @Override + public void shutdownOutput() { + delegate.shutdownOutput(); + } + + @Override + public boolean isOutputShutdown() { + return delegate.isOutputShutdown(); + } + + @Override + public boolean isInputShutdown() { + return delegate.isInputShutdown(); + } + + @Override + public void close(Throwable cause) { + delegate.close(cause); + } + + @Override + public Object getTransport() { + return delegate.getTransport(); + } + + @Override + public long getIdleTimeout() { + return delegate.getIdleTimeout(); + } + + @Override + public void setIdleTimeout(long idleTimeout) { + delegate.setIdleTimeout(idleTimeout); + } + + @Override + public void fillInterested(Callback callback) throws ReadPendingException { + delegate.fillInterested(callback); + } + + @Override + public boolean tryFillInterested(Callback callback) { + return delegate.tryFillInterested(callback); + } + + @Override + public boolean isFillInterested() { + return delegate.isFillInterested(); + } + + @Override + public void write(Callback callback, ByteBuffer... buffers) throws WritePendingException { + delegate.write(callback, maybePatch(buffers)); + } + + @Override + public void write(Callback callback, SocketAddress address, ByteBuffer... buffers) + throws WritePendingException { + delegate.write(callback, address, maybePatch(buffers)); + } + + @Override + public void write(boolean last, ByteBuffer buffer, Callback callback) { + delegate.write(last, maybePatch(buffer), callback); + } + + @Override + public Callback cancelWrite(Throwable cause) { + return delegate.cancelWrite(cause); + } + + @Override + public Connection getConnection() { + return delegate.getConnection(); + } + + @Override + public void setConnection(Connection connection) { + delegate.setConnection(connection); + } + + @Override + public void onOpen() { + delegate.onOpen(); + } + + @Override + public void onClose(Throwable cause) { + delegate.onClose(cause); + } + + @Override + public void upgrade(Connection newConnection) { + delegate.upgrade(newConnection); + } + + private ByteBuffer[] maybePatch(ByteBuffer... buffers) { + if (!patchRequestLineToHttp11 || buffers == null) { + return buffers; + } + ByteBuffer[] patched = new ByteBuffer[buffers.length]; + for (int i = 0; i < buffers.length; i++) { + patched[i] = maybePatch(buffers[i]); + } + return patched; + } + + private ByteBuffer maybePatch(ByteBuffer buffer) { + if (!patchRequestLineToHttp11 || buffer == null) { + return buffer; + } + patchRequestLineToHttp11(buffer); + return buffer; + } + + static void patchRequestLineToHttp11(ByteBuffer buffer) { + int start = buffer.position(); + int end = buffer.limit(); + for (int i = start; i <= end - 8; i++) { + if (buffer.get(i) == 'H' + && buffer.get(i + 1) == 'T' + && buffer.get(i + 2) == 'T' + && buffer.get(i + 3) == 'P' + && buffer.get(i + 4) == '/' + && buffer.get(i + 5) == '1' + && buffer.get(i + 6) == '.' + && buffer.get(i + 7) == '0') { + buffer.put(i + 7, (byte) '1'); + return; + } + if (buffer.get(i) == '\n') { + return; + } + } + } +} diff --git a/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java b/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java index 529c270..cbc9127 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java +++ b/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java @@ -6,6 +6,7 @@ import com.blazemeter.jmeter.http2.core.HTTP2ClientProfileConfig; import com.blazemeter.jmeter.http2.core.HTTP2FutureResponseListener; import com.blazemeter.jmeter.http2.core.HTTP2JettyClient; +import com.blazemeter.jmeter.http2.core.JmeterHttpClientExceptionMapper; import com.blazemeter.jmeter.http2.core.ProtocolErrorException; import com.blazemeter.jmeter.http2.util.BzmHttpPluginProperties; import com.github.benmanes.caffeine.cache.Caffeine; @@ -119,28 +120,7 @@ protected Map childValue( JMeterUtils.getProperty("HTTPResponse.parsers"); //$NON-NLS-1$ static { - String[] parsers = - JOrphanUtils.split(RESPONSE_PARSERS, " ", true); // returns empty array for null - for (final String parser : parsers) { - String classname = JMeterUtils.getProperty(parser + ".className"); //$NON-NLS-1$ - if (classname == null) { - LOG.error("Cannot find .className property for {}, ensure you set property: '{}.className'", - parser, parser); - continue; - } - String typeList = JMeterUtils.getProperty(parser + ".types"); //$NON-NLS-1$ - if (typeList != null) { - String[] types = JOrphanUtils.split(typeList, " ", true); - for (final String type : types) { - registerParser(type, classname); - } - } else { - LOG.warn( - "Cannot find .types property for {}, as a consequence parser " + - "will not be used, to make it usable, define property:'{}.types'", - parser, parser); - } - } + loadResponseParsersFromProperties(); } private final transient Callable clientFactory; @@ -626,7 +606,9 @@ private HTTPSampleResult buildErrorResult(Exception e, HTTPSampleResult result) result.sampleEnd(); } } - return errorResult(e, result); + return errorResult( + JmeterHttpClientExceptionMapper.forSampleResult(e, getAutoRedirects(), result.getURL()), + result); } /** @@ -636,6 +618,47 @@ private HTTPSampleResult buildErrorResult(Exception e, HTTPSampleResult result) * the global HTTP/1.1-only origin cache ({@link HTTP2JettyClient}) even when the parent has * HTTP/1.1 explicitly disabled (e.g. HTTP/2-only mode). */ + private HTTP2Sampler newFileEmbeddedSampler(URL url) { + HTTP2Sampler fileSampler = new HTTP2Sampler(); + copyJettyProtocolSettingsToEmbeddedSampler(fileSampler); + String path = url.getPath(); + boolean htmlResource = path != null + && (path.endsWith(".html") || path.endsWith(".htm")); + fileSampler.setImageParser(htmlResource); + fileSampler.setMethod(HTTPConstants.GET); + fileSampler.setProtocol(url.getProtocol()); + fileSampler.setDomain(url.getHost()); + fileSampler.setPort(url.getPort()); + if (url.getQuery() == null) { + fileSampler.setPath(url.getPath()); + } else { + fileSampler.setPath(url.getPath() + url.getQuery()); + } + fileSampler.setHeaderManager(getHeaderManager()); + fileSampler.setCookieManager(getCookieManager()); + return fileSampler; + } + + private static String formatFileEmbeddedLabel(URL url, int index) { + String path = url.getPath(); + if (path != null && path.startsWith("/")) { + path = path.substring(1); + } + return url.getProtocol() + ":" + path + "-" + index; + } + + private static void relabelFileEmbeddedChildren(HTTPSampleResult parent) { + int childIndex = 0; + for (SampleResult child : parent.getSubResults()) { + if (child instanceof HTTPSampleResult httpChild && httpChild.getURL() != null + && "file".equalsIgnoreCase(httpChild.getURL().getProtocol())) { + httpChild.setSampleLabel( + formatFileEmbeddedLabel(httpChild.getURL(), childIndex++)); + relabelFileEmbeddedChildren(httpChild); + } + } + } + private void copyJettyProtocolSettingsToEmbeddedSampler(HTTP2Sampler embedded) { embedded.setProfile(getProfile()); embedded.setEnableHttp3(getEnableHttp3()); @@ -736,8 +759,49 @@ static void registerParser(String contentType, String className) { PARSERS_FOR_CONTENT_TYPE.put(contentType, className); } + /** + * JMeter batch plans configure HTML parsers via {@code -q jmeter-batch.properties}. The plugin + * class may load before those properties exist, so register parsers lazily on first use. + */ + private static void ensureResponseParsersLoaded() { + if (!PARSERS_FOR_CONTENT_TYPE.isEmpty()) { + return; + } + synchronized (PARSERS_FOR_CONTENT_TYPE) { + if (PARSERS_FOR_CONTENT_TYPE.isEmpty()) { + loadResponseParsersFromProperties(); + } + } + } + + private static void loadResponseParsersFromProperties() { + String responseParsers = JMeterUtils.getProperty("HTTPResponse.parsers"); + String[] parsers = JOrphanUtils.split(responseParsers, " ", true); + for (final String parser : parsers) { + String classname = JMeterUtils.getProperty(parser + ".className"); + if (classname == null) { + LOG.error("Cannot find .className property for {}, ensure you set property: '{}.className'", + parser, parser); + continue; + } + String typeList = JMeterUtils.getProperty(parser + ".types"); + if (typeList != null) { + String[] types = JOrphanUtils.split(typeList, " ", true); + for (final String type : types) { + registerParser(type, classname); + } + } else { + LOG.warn( + "Cannot find .types property for {}, as a consequence parser " + + "will not be used, to make it usable, define property:'{}.types'", + parser, parser); + } + } + } + private LinkExtractorParser getParser(HTTPSampleResult res) throws LinkExtractorParseException { + ensureResponseParsersLoaded(); String parserClassName = PARSERS_FOR_CONTENT_TYPE.get(res.getMediaType()); if (!StringUtils.isEmpty(parserClassName)) { @@ -755,13 +819,12 @@ private String getUserAgent(HTTPSampleResult sampleResult) { // see HTTPJavaImpl#getConnectionHeaders //': ' is used by JMeter to fill-in requestHeaders, see getConnectionHeaders final String userAgentPrefix = USER_AGENT + ": "; - String userAgentHdr = res.substring( - index + userAgentPrefix.length(), - res.indexOf( - '\n', - // '\n' is used by JMeter to fill-in requestHeaders, see getConnectionHeaders - index + userAgentPrefix.length() + 1)); - return userAgentHdr.trim(); + int valueStart = index + userAgentPrefix.length(); + int lineEnd = res.indexOf('\n', valueStart); + if (lineEnd < 0) { + lineEnd = res.length(); + } + return res.substring(valueStart, lineEnd).trim(); } else { if (LOG.isDebugEnabled()) { LOG.debug("No user agent extracted from requestHeaders:{}", res); @@ -928,6 +991,7 @@ protected HTTPSampleResult downloadPageResources(final HTTPSampleResult pRes, setSyncRequest(!isConcurrentDwn); // Change default from main request based on sub request + int fileEmbeddedIndex = 0; while (urls.hasNext()) { Object binURL = urls.next(); // See catch clause below try { @@ -960,6 +1024,20 @@ protected HTTPSampleResult downloadPageResources(final HTTPSampleResult pRes, continue; } + if ("file".equalsIgnoreCase(url.getProtocol())) { + HTTP2Sampler fileSampler = newFileEmbeddedSampler(url); + HTTPSampleResult binRes = + fileSampler.sample(url, HTTPConstants.GET, false, frameDepth + 1); + if (binRes != null) { + binRes.setSampleLabel(formatFileEmbeddedLabel(url, fileEmbeddedIndex++)); + relabelFileEmbeddedChildren(binRes); + } + subres.addSubResult(binRes); + setParentSampleSuccess(subres, + subres.isSuccessful() && (binRes == null || binRes.isSuccessful())); + continue; + } + HTTP2Sampler h2s = new HTTP2Sampler(); copyJettyProtocolSettingsToEmbeddedSampler(h2s); h2s.setMethod("GET"); diff --git a/src/main/java/com/blazemeter/jmeter/http2/sampler/JmxBlazeMeterHttpMigrator.java b/src/main/java/com/blazemeter/jmeter/http2/sampler/JmxBlazeMeterHttpMigrator.java new file mode 100644 index 0000000..5ba09f2 --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/sampler/JmxBlazeMeterHttpMigrator.java @@ -0,0 +1,151 @@ +package com.blazemeter.jmeter.http2.sampler; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; +import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase; +import org.apache.jmeter.save.SaveService; +import org.apache.jmeter.testelement.TestElement; +import org.apache.jorphan.collections.HashTree; +import org.apache.jorphan.collections.ListedHashTree; + +/** + * Headless migration of JMeter test plans: replaces stock HTTP Request samplers with + * {@link HTTP2Sampler} while preserving child elements (assertions, timers, etc.). + */ +public final class JmxBlazeMeterHttpMigrator { + + private JmxBlazeMeterHttpMigrator() { + } + + public static HashTree loadTree(File jmxFile) throws IOException { + return SaveService.loadTree(jmxFile); + } + + public static void saveTree(HashTree tree, File jmxFile) throws IOException { + try (OutputStream out = java.nio.file.Files.newOutputStream(jmxFile.toPath())) { + SaveService.saveTree(tree, out); + } + } + + /** + * @return number of HTTP Request samplers replaced + */ + public static int migrateTree(HashTree tree) { + return migrateTreeWithDetails(tree).getReplacedCount(); + } + + public static MigrationResult migrateTreeWithDetails(HashTree tree) { + MigrationResult result = new MigrationResult(); + migrateInPlace(tree, result); + return result; + } + + public static HashTree migrateCopy(HashTree source) { + return migrateCopy(source, new MigrationResult()); + } + + public static HashTree migrateCopy(HashTree source, MigrationResult result) { + ListedHashTree copy = new ListedHashTree(); + for (Object key : source.list()) { + Object newKey = maybeReplaceSampler(key, result); + HashTree sub = source.getTree(key); + if (sub != null && !sub.isEmpty()) { + copy.add(newKey, migrateCopy(sub, result)); + } else { + copy.add(newKey); + } + } + return copy; + } + + public static File migrateFile(File sourceJmx, File targetJmx) throws IOException { + HashTree tree = loadTree(sourceJmx); + migrateTree(tree); + saveTree(tree, targetJmx); + return targetJmx; + } + + public static int countMigratableSamplers(HashTree tree) { + int count = 0; + for (Object key : tree.list()) { + if (key instanceof TestElement + && HttpSamplerToBlazeMeterHttpMigrator.isMigratableApacheHttpSampler( + (TestElement) key)) { + count++; + } + HashTree sub = tree.getTree(key); + if (sub != null && !sub.isEmpty()) { + count += countMigratableSamplers(sub); + } + } + return count; + } + + public static int countHttp2Samplers(HashTree tree) { + int count = 0; + for (Object key : tree.list()) { + if (key instanceof HTTP2Sampler) { + count++; + } + HashTree sub = tree.getTree(key); + if (sub != null && !sub.isEmpty()) { + count += countHttp2Samplers(sub); + } + } + return count; + } + + private static void migrateInPlace(HashTree tree, MigrationResult result) { + List keys = new ArrayList<>(tree.list()); + for (Object key : keys) { + HashTree sub = tree.getTree(key); + if (sub != null && !sub.isEmpty()) { + migrateInPlace(sub, result); + } + if (key instanceof TestElement + && HttpSamplerToBlazeMeterHttpMigrator.isMigratableApacheHttpSampler( + (TestElement) key)) { + HTTPSamplerBase source = (HTTPSamplerBase) key; + HTTP2Sampler replacement = + HttpSamplerToBlazeMeterHttpMigrator.migrateFromApacheHttpSampler(source); + result.recordReplacement(source, replacement); + tree.replaceKey(key, replacement); + } + } + } + + private static Object maybeReplaceSampler(Object key, MigrationResult result) { + if (key instanceof TestElement + && HttpSamplerToBlazeMeterHttpMigrator.isMigratableApacheHttpSampler( + (TestElement) key)) { + HTTPSamplerBase source = (HTTPSamplerBase) key; + HTTP2Sampler replacement = + HttpSamplerToBlazeMeterHttpMigrator.migrateFromApacheHttpSampler(source); + result.recordReplacement(source, replacement); + return replacement; + } + return key; + } + + public static final class MigrationResult { + private int replacedCount; + private final List replacedLabels = new ArrayList<>(); + + private void recordReplacement(HTTPSamplerBase source, HTTP2Sampler replacement) { + replacedCount++; + replacedLabels.add(source.getName()); + replacement.setName(source.getName()); + } + + public int getReplacedCount() { + return replacedCount; + } + + public List getReplacedLabels() { + return replacedLabels; + } + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java index 2233d21..d1e18ae 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java @@ -280,9 +280,8 @@ public void shouldReturnSuccessSampleResultWhenSuccessResponseWithContentTypeGzi sampler.setHeaderManager(hm); try { HTTPSampleResult result = sampleWithGet(SERVER_PATH_200_GZIP); - boolean hasGzipHeader = result.getResponseHeaders().contains("Content-Encoding: gzip"); - boolean hasBody = result.getResponseData() != null && result.getResponseData().length > 0; - assertThat(hasGzipHeader || hasBody).isTrue(); + assertThat(result.getResponseHeaders()).containsIgnoringCase("content-encoding: gzip"); + assertThat(result.getResponseData()).containsExactly(BINARY_RESPONSE_BODY); } finally { if (originalEnableHttp1 == null) { JMeterUtils.getJMeterProperties().remove("httpJettyClient.enableHttp1"); @@ -411,7 +410,7 @@ public void shouldSendBodyInformationWhenRequestWithBodyRaw() throws Exception { String requestBody = TEST_ARGUMENT_1 + TEST_ARGUMENT_2; HTTPSampleResult httpSampleResult = buildResult(true, Code.OK, hostHeader(), - requestBody.getBytes(StandardCharsets.UTF_8), "application/octet-stream", + requestBody.getBytes(StandardCharsets.UTF_8), "text/plain", createURL(SERVER_PATH_200_WITH_BODY), HTTPConstants.POST); validateResponse(sample(SERVER_PATH_200_WITH_BODY, HTTPConstants.POST), httpSampleResult); @@ -586,7 +585,7 @@ public void shouldSendBodyWhenDeleteMethodWithRawData() throws Exception { buildResult(true, HttpStatus.Code.OK, HttpFields.build().add(HttpHeader.HOST, hostHeaderValue()), requestBody.getBytes(StandardCharsets.UTF_8), - "application/octet-stream", createURL(SERVER_PATH_200_WITH_BODY), + "text/plain", createURL(SERVER_PATH_200_WITH_BODY), HTTPConstants.DELETE); validateResponse(sample(SERVER_PATH_200_WITH_BODY, HTTPConstants.DELETE), expected); @@ -1049,7 +1048,10 @@ private HTTPSampleResult buildMultipartResult(List args, List { Mutable headerParam = HttpFields.build() .add("Content-Disposition", "form-data; name=\"" + httpArgument.getEncodedName() + "\"") - .add(HttpHeader.CONTENT_TYPE, "text/plain; charset=utf-8"); + .add(HttpHeader.CONTENT_TYPE, "text/plain; charset=utf-8") + .add("Content-Transfer-Encoding", "8bit"); try { String headerParamWithBoundary = boundary + newLine + headerParam.toString(); output.write(headerParamWithBoundary.getBytes(StandardCharsets.US_ASCII)); @@ -1089,7 +1092,8 @@ private byte[] buildByteArrayFromFilesAndParams(HTTPSampleResult expected, Mutable headerFile = HttpFields.build() .add("Content-Disposition", "form-data; name=\"" + file.getParamName() + "\"; " + "filename=\"" + fileName + "\"") - .add(HttpHeader.CONTENT_TYPE, file.getMimeType()); + .add(HttpHeader.CONTENT_TYPE, file.getMimeType()) + .add("Content-Transfer-Encoding", "binary"); try { String filePath = file.getPath(); InputStream inputStream = Files.newInputStream(Paths.get(filePath)); diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/JmeterCompressionHeadersSupportTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/JmeterCompressionHeadersSupportTest.java new file mode 100644 index 0000000..6818707 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/JmeterCompressionHeadersSupportTest.java @@ -0,0 +1,95 @@ +package com.blazemeter.jmeter.http2.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; + +import java.util.HashMap; +import java.util.Map; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.eclipse.jetty.client.ContentResponse; +import org.eclipse.jetty.client.Request; +import org.eclipse.jetty.http.HttpFields; +import org.eclipse.jetty.http.HttpHeader; +import org.junit.Test; +import org.mockito.Mockito; + +public class JmeterCompressionHeadersSupportTest { + + private static Request mockRequestWithAttributes() { + Map attributes = new HashMap<>(); + Request request = Mockito.mock(Request.class); + Mockito.when(request.getAttributes()).thenReturn(attributes); + Mockito.doAnswer(invocation -> { + attributes.put(invocation.getArgument(0), invocation.getArgument(1)); + return invocation.getMock(); + }).when(request).attribute(anyString(), any()); + return request; + } + + @Test + public void shouldRestoreWireCompressionHeadersRemovedByDecoder() { + Request request = mockRequestWithAttributes(); + + HttpFields wire = HttpFields.build() + .add(HttpHeader.CONTENT_ENCODING, "gzip") + .add(HttpHeader.CONTENT_LENGTH, "643"); + JmeterCompressionHeadersSupport.captureIfCompressed(request, wire); + + ContentResponse contentResponse = Mockito.mock(ContentResponse.class); + HttpFields decoded = HttpFields.build() + .add(HttpHeader.CONTENT_TYPE, "application/json") + .add(HttpHeader.CONTENT_LENGTH, "238"); + Mockito.when(contentResponse.getRequest()).thenReturn(request); + Mockito.when(contentResponse.getHeaders()).thenReturn(decoded); + + HttpFields merged = JmeterCompressionHeadersSupport.headersForSampleResult(contentResponse); + + assertThat(merged.get(HttpHeader.CONTENT_ENCODING)).isEqualTo("gzip"); + assertThat(merged.get(HttpHeader.CONTENT_LENGTH)).isEqualTo("643"); + assertThat(merged.get(HttpHeader.CONTENT_TYPE)).isEqualTo("application/json"); + } + + @Test + public void shouldNotOverrideExistingHeaders() { + Request request = mockRequestWithAttributes(); + + HttpFields wire = HttpFields.build() + .add(HttpHeader.CONTENT_ENCODING, "gzip") + .add(HttpHeader.CONTENT_LENGTH, "643"); + JmeterCompressionHeadersSupport.captureIfCompressed(request, wire); + + ContentResponse contentResponse = Mockito.mock(ContentResponse.class); + HttpFields decoded = HttpFields.build() + .add(HttpHeader.CONTENT_ENCODING, "gzip") + .add(HttpHeader.CONTENT_LENGTH, "238"); + Mockito.when(contentResponse.getRequest()).thenReturn(request); + Mockito.when(contentResponse.getHeaders()).thenReturn(decoded); + + HttpFields merged = JmeterCompressionHeadersSupport.headersForSampleResult(contentResponse); + + assertThat(merged.get(HttpHeader.CONTENT_LENGTH)).isEqualTo("238"); + } + + @Test + public void shouldCaptureContentMd5WhenPresent() { + Request request = mockRequestWithAttributes(); + + HttpFields wire = HttpFields.build() + .add(HttpHeader.CONTENT_ENCODING, "br") + .add(HTTPConstants.HEADER_CONTENT_LENGTH, "100") + .add("Content-MD5", "abc123"); + JmeterCompressionHeadersSupport.captureIfCompressed(request, wire); + + ContentResponse contentResponse = Mockito.mock(ContentResponse.class); + HttpFields decoded = HttpFields.build().add(HttpHeader.CONTENT_TYPE, "text/plain"); + Mockito.when(contentResponse.getRequest()).thenReturn(request); + Mockito.when(contentResponse.getHeaders()).thenReturn(decoded); + + HttpFields merged = JmeterCompressionHeadersSupport.headersForSampleResult(contentResponse); + + assertThat(merged.get(HttpHeader.CONTENT_ENCODING)).isEqualTo("br"); + assertThat(merged.get(HTTPConstants.HEADER_CONTENT_LENGTH)).isEqualTo("100"); + assertThat(merged.get("Content-MD5")).isEqualTo("abc123"); + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/JmeterHttpClientExceptionMapperTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/JmeterHttpClientExceptionMapperTest.java new file mode 100644 index 0000000..feb92b8 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/JmeterHttpClientExceptionMapperTest.java @@ -0,0 +1,48 @@ +package com.blazemeter.jmeter.http2.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.ConnectException; +import java.net.URL; +import java.util.concurrent.ExecutionException; +import org.apache.http.client.ClientProtocolException; +import org.apache.http.conn.HttpHostConnectException; +import org.eclipse.jetty.client.HttpResponseException; +import org.junit.Test; + +public class JmeterHttpClientExceptionMapperTest { + + @Test + public void shouldMapJettyMaxRedirectsToClientProtocolExceptionWhenAutoRedirectsEnabled() { + Throwable jetty = new ExecutionException( + new HttpResponseException("Max redirects exceeded 8", null)); + + Throwable mapped = JmeterHttpClientExceptionMapper.forSampleResult(jetty, true); + + assertThat(mapped).isInstanceOf(ClientProtocolException.class); + assertThat(mapped.getMessage()).isNull(); + } + + @Test + public void shouldKeepExecutionExceptionWhenAutoRedirectsDisabled() { + Throwable failure = new ExecutionException( + new HttpResponseException("Max redirects exceeded 8", null)); + + Throwable mapped = JmeterHttpClientExceptionMapper.forSampleResult(failure, false); + + assertThat(mapped).isInstanceOf(ExecutionException.class); + } + + @Test + public void shouldMapConnectExceptionToHttpHostConnectException() throws Exception { + Throwable failure = new ExecutionException( + new ConnectException("Connection refused: getsockopt")); + URL url = new URL("https://localhost:8082/"); + + Throwable mapped = JmeterHttpClientExceptionMapper.forSampleResult(failure, false, url); + + assertThat(mapped).isInstanceOf(HttpHostConnectException.class); + assertThat(mapped.getCause()).isInstanceOf(ConnectException.class); + assertThat(mapped.getMessage()).contains("localhost:8082"); + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/CustomHttpKeepAliveWireIntegrationTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/CustomHttpKeepAliveWireIntegrationTest.java new file mode 100644 index 0000000..8b99ee5 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/CustomHttpKeepAliveWireIntegrationTest.java @@ -0,0 +1,307 @@ +package com.blazemeter.jmeter.http2.core.jetty.custom.http1; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.blazemeter.jmeter.http2.core.HTTP2ClientProfileConfig; +import com.blazemeter.jmeter.http2.core.HTTP2JettyClient; +import com.blazemeter.jmeter.http2.core.JmeterHttpClientAttributes; +import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler; +import com.blazemeter.jmeter.http2.sampler.JMeterTestUtils; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import org.apache.jmeter.protocol.http.util.HTTPFileArg; +import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; +import org.apache.jmeter.util.JMeterUtils; +import org.junit.BeforeClass; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.HttpClientTransport; +import org.eclipse.jetty.client.Request; +import org.eclipse.jetty.client.transport.HttpClientTransportDynamic; +import org.eclipse.jetty.http.HttpFields; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpVersion; +import org.eclipse.jetty.io.ClientConnector; +import org.eclipse.jetty.util.thread.QueuedThreadPool; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class CustomHttpKeepAliveWireIntegrationTest { + + @BeforeClass + public static void setupJmeter() { + JMeterTestUtils.setupJmeterEnv(); + } + + private ClientConnector connector; + private HttpClient client; + private ExecutorService executor; + + @Before + public void setUp() throws Exception { + connector = new ClientConnector(); + connector.setSelectors(1); + QueuedThreadPool threadPool = new QueuedThreadPool(); + threadPool.setName("keepalive-wire-test"); + connector.setExecutor(threadPool); + executor = Executors.newSingleThreadExecutor(); + CustomHttpClientConnectionFactory.HTTP11 http11 = new CustomHttpClientConnectionFactory.HTTP11(); + HttpClientTransport transport = new HttpClientTransportDynamic(connector, http11); + client = new HttpClient(transport); + client.setUserAgentField(null); + client.start(); + } + + @After + public void tearDown() throws Exception { + if (client != null) { + client.stop(); + } + if (executor != null) { + executor.shutdownNow(); + executor.awaitTermination(5, TimeUnit.SECONDS); + } + } + + @Test + public void http2JettyClientEchoesConnectionKeepAliveOnPlainHttp() throws Exception { + JMeterUtils.setProperty("blazemeter.http.enableHttp2", "false"); + JMeterUtils.setProperty("blazemeter.http.enableHttp3", "false"); + JMeterUtils.setProperty("blazemeter.http.profile", "legacy"); + + try (ServerSocket serverSocket = new ServerSocket(0)) { + int port = serverSocket.getLocalPort(); + Future received = executor.submit(() -> readRequest(serverSocket)); + + HTTP2JettyClient jettyClient = new HTTP2JettyClient(false, "keepalive-echo-test"); + jettyClient.start(); + try { + HTTP2Sampler sampler = new HTTP2Sampler(); + sampler.setMethod("GET"); + sampler.setDomain("localhost"); + sampler.setPort(port); + sampler.setPath("/test"); + sampler.setProtocol("http"); + sampler.setUseKeepAlive(true); + + URL url = new URL("http", "localhost", port, "/test"); + HTTPSampleResult result = new HTTPSampleResult(); + result.setSampleLabel("GET_NO_PARAMETERS"); + result.setHTTPMethod("GET"); + result.setURL(url); + + HTTPSampleResult sampleResult = jettyClient.sample(sampler, result, false, 0); + String wireRequest = received.get(10, TimeUnit.SECONDS); + + assertThat(wireRequest).contains("GET /test HTTP/1.1"); + assertThat(wireRequest).contains("Connection: keep-alive"); + assertThat(wireRequest).contains("User-Agent: "); + assertThat(sampleResult.getResponseDataAsString()).contains("Connection: keep-alive"); + assertThat(sampleResult.getResponseDataAsString()).contains("User-Agent: "); + } finally { + jettyClient.stop(); + } + } + } + + @Test + public void http2SamplerEchoesConnectionKeepAliveOnPlainHttp() throws Exception { + JMeterUtils.setProperty("blazemeter.http.enableHttp2", "false"); + JMeterUtils.setProperty("blazemeter.http.enableHttp3", "false"); + JMeterUtils.setProperty("blazemeter.http.enableHttp1", "true"); + JMeterUtils.setProperty("blazemeter.http.profile", "legacy"); + JMeterUtils.setProperty("blazemeter.http.altSvcCacheEnabled", "false"); + JMeterUtils.setProperty("blazemeter.http.h2cCacheEnabled", "false"); + + try (ServerSocket serverSocket = new ServerSocket(0)) { + int port = serverSocket.getLocalPort(); + Future received = executor.submit(() -> readRequest(serverSocket)); + + HTTP2Sampler sampler = new HTTP2Sampler(); + sampler.setMethod("GET"); + sampler.setDomain("localhost"); + sampler.setPort(port); + sampler.setPath("/test"); + sampler.setProtocol("http"); + sampler.setUseKeepAlive(true); + + HTTP2JettyClient jettyClient = new HTTP2JettyClient(false, "sampler-keepalive-test", + HTTP2ClientProfileConfig.builder().profile("browser-like").build()); + jettyClient.start(); + try { + URL url = sampler.getUrl(); + HTTPSampleResult result = new HTTPSampleResult(); + result.setSampleLabel("GET_NO_PARAMETERS"); + result.setHTTPMethod("GET"); + result.setURL(url); + HTTPSampleResult sampleResult = jettyClient.sample(sampler, result, false, 0); + String wireRequest = received.get(10, TimeUnit.SECONDS); + + assertThat(wireRequest).contains("GET /test HTTP/1.1"); + assertThat(wireRequest).contains("Connection: keep-alive"); + assertThat(wireRequest).contains("User-Agent: "); + assertThat(sampleResult.getResponseDataAsString()).contains("Connection: keep-alive"); + assertThat(sampleResult.getResponseDataAsString()).contains("User-Agent: "); + } finally { + jettyClient.stop(); + } + } + } + + @Test + public void getWithFileArgDoesNotSendEntityHeadersOnWire() throws Exception { + java.nio.file.Path tempFile = Files.createTempFile("get-with-file", ".xml"); + Files.writeString(tempFile, ""); + try { + JMeterUtils.setProperty("blazemeter.http.enableHttp2", "false"); + JMeterUtils.setProperty("blazemeter.http.enableHttp3", "false"); + JMeterUtils.setProperty("blazemeter.http.profile", "legacy"); + + try (ServerSocket serverSocket = new ServerSocket(0)) { + int port = serverSocket.getLocalPort(); + Future received = executor.submit(() -> readRequest(serverSocket)); + + HTTP2Sampler sampler = new HTTP2Sampler(); + sampler.setMethod("GET"); + sampler.setDomain("localhost"); + sampler.setPort(port); + sampler.setPath("/test?name=value"); + sampler.setProtocol("http"); + sampler.setUseKeepAlive(true); + sampler.setDoMultipart(false); + sampler.setHTTPFiles(new HTTPFileArg[] { + new HTTPFileArg(tempFile.toString(), "file", "text/xml") + }); + + HTTP2JettyClient jettyClient = new HTTP2JettyClient(false, "get-file-wire-test"); + jettyClient.start(); + try { + URL url = sampler.getUrl(); + HTTPSampleResult result = new HTTPSampleResult(); + result.setSampleLabel("GET_WITH_PARAMETERS_IN_URL"); + result.setHTTPMethod("GET"); + result.setURL(url); + HTTPSampleResult sampleResult = jettyClient.sample(sampler, result, false, 0); + String wireRequest = received.get(10, TimeUnit.SECONDS); + + assertThat(wireRequest).contains("GET /test?name=value HTTP/1.1"); + assertThat(wireRequest).doesNotContain("Content-Length:"); + assertThat(wireRequest).doesNotContain("Content-Type:"); + assertThat(sampleResult.getRequestHeaders()).doesNotContain("Content-Length:"); + assertThat(sampleResult.getRequestHeaders()).doesNotContain("Content-Type:"); + } finally { + jettyClient.stop(); + } + } + } finally { + Files.deleteIfExists(tempFile); + } + } + + @Test + public void http2ProfileEchoesConnectionKeepAliveWhenH2cUpgradeFails() throws Exception { + JMeterUtils.setProperty("blazemeter.http.enableHttp2", "true"); + JMeterUtils.setProperty("blazemeter.http.enableHttp3", "false"); + JMeterUtils.setProperty("blazemeter.http.enableHttp1", "true"); + JMeterUtils.setProperty("blazemeter.http.profile", "browser-compatible"); + JMeterUtils.setProperty("blazemeter.http.altSvcCacheEnabled", "false"); + + try (ServerSocket serverSocket = new ServerSocket(0)) { + int port = serverSocket.getLocalPort(); + Future received = executor.submit(() -> readRequestWithKeepAlive(serverSocket)); + + HTTP2JettyClient jettyClient = new HTTP2JettyClient(true, "h2c-fallback-keepalive-test", + HTTP2ClientProfileConfig.builder().profile("browser-compatible") + .enableHttp2(true).enableHttp1(true).build()); + jettyClient.start(); + try { + HTTP2Sampler sampler = new HTTP2Sampler(); + sampler.setMethod("GET"); + sampler.setDomain("localhost"); + sampler.setPort(port); + sampler.setPath("/test"); + sampler.setProtocol("http"); + sampler.setUseKeepAlive(true); + + URL url = new URL("http", "localhost", port, "/test"); + HTTPSampleResult result = new HTTPSampleResult(); + result.setSampleLabel("GET_WITH_PARAMETERS_IN_URL"); + result.setHTTPMethod("GET"); + result.setURL(url); + + HTTPSampleResult sampleResult = jettyClient.sample(sampler, result, false, 0); + String wireRequest = received.get(10, TimeUnit.SECONDS); + + assertThat(wireRequest).contains("GET /test HTTP/1.1"); + assertThat(wireRequest).contains("Connection: keep-alive"); + assertThat(sampleResult.getResponseDataAsString()).contains("Connection: keep-alive"); + } finally { + jettyClient.stop(); + } + } + } + + @Test + public void emitsExplicitConnectionKeepAliveOnWire() throws Exception { + try (ServerSocket serverSocket = new ServerSocket(0)) { + int port = serverSocket.getLocalPort(); + Future received = executor.submit(() -> readRequest(serverSocket)); + + Request request = client.newRequest("http://localhost:" + port + "/test") + .version(HttpVersion.HTTP_1_1) + .attribute(JmeterHttpClientAttributes.USE_KEEPALIVE, Boolean.TRUE); + HttpFields headers = request.getHeaders(); + if (headers instanceof HttpFields.Mutable) { + ((HttpFields.Mutable) headers).put(HttpHeader.CONNECTION, "keep-alive"); + } + + try { + request.send(); + } catch (Exception ignored) { + // Response parsing is irrelevant for wire-level assertions. + } + String wireRequest = received.get(10, TimeUnit.SECONDS); + + assertThat(wireRequest).contains("GET /test HTTP/1.1"); + assertThat(wireRequest).contains("Connection: keep-alive"); + } + } + + private static String readRequest(ServerSocket serverSocket) throws Exception { + return readAndEchoRequest(serverSocket); + } + + private static String readRequestWithKeepAlive(ServerSocket serverSocket) throws Exception { + String request = ""; + for (int attempt = 0; attempt < 3; attempt++) { + request = readAndEchoRequest(serverSocket); + if (request.contains("Connection: keep-alive")) { + return request; + } + } + return request; + } + + private static String readAndEchoRequest(ServerSocket serverSocket) throws Exception { + try (Socket socket = serverSocket.accept(); + InputStream in = socket.getInputStream(); + OutputStream out = socket.getOutputStream()) { + byte[] buffer = new byte[4096]; + int read = in.read(buffer); + String request = new String(buffer, 0, read, StandardCharsets.US_ASCII); + out.write("HTTP/1.0 200 OK\r\nContent-Type: text/plain\r\n\r\n".getBytes(StandardCharsets.US_ASCII)); + out.write(buffer, 0, read); + out.flush(); + return request; + } + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/KeepAliveParityEndPointTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/KeepAliveParityEndPointTest.java new file mode 100644 index 0000000..866221d --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/KeepAliveParityEndPointTest.java @@ -0,0 +1,32 @@ +package com.blazemeter.jmeter.http2.core.jetty.custom.http1; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import org.junit.Test; + +public class KeepAliveParityEndPointTest { + + @Test + public void patchesHttp10RequestLineToHttp11() { + ByteBuffer buffer = ByteBuffer.wrap( + "GET /test HTTP/1.0\r\nConnection: keep-alive\r\n\r\n".getBytes(StandardCharsets.US_ASCII)); + + KeepAliveParityEndPoint.patchRequestLineToHttp11(buffer); + + assertThat(StandardCharsets.US_ASCII.decode(buffer).toString()) + .startsWith("GET /test HTTP/1.1\r\n"); + } + + @Test + public void leavesHttp11RequestLineUnchanged() { + ByteBuffer buffer = ByteBuffer.wrap( + "GET /test HTTP/1.1\r\nConnection: keep-alive\r\n\r\n".getBytes(StandardCharsets.US_ASCII)); + + KeepAliveParityEndPoint.patchRequestLineToHttp11(buffer); + + assertThat(StandardCharsets.US_ASCII.decode(buffer).toString()) + .startsWith("GET /test HTTP/1.1\r\n"); + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/parity/HttpCacheManagerParityTest.java b/src/test/java/com/blazemeter/jmeter/http2/parity/HttpCacheManagerParityTest.java new file mode 100644 index 0000000..1dd1180 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/parity/HttpCacheManagerParityTest.java @@ -0,0 +1,93 @@ +package com.blazemeter.jmeter.http2.parity; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import com.blazemeter.jmeter.http2.core.HTTP2JettyClient; +import com.blazemeter.jmeter.http2.core.ServerBuilder; +import com.blazemeter.jmeter.http2.core.ServerBuilder.TeardownableServer; +import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler; +import com.blazemeter.jmeter.http2.sampler.JMeterTestUtils; +import java.net.URL; +import org.apache.jmeter.protocol.http.control.CacheManager; +import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.apache.jmeter.util.JMeterUtils; +import org.eclipse.jetty.server.ServerConnector; +import org.junit.After; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +/** Port of cache behaviour from Apache {@code TestCacheManagerHC4} (embedded resource caching). */ +public class HttpCacheManagerParityTest extends HTTP2TestBase { + + private TeardownableServer server; + private HTTP2JettyClient client; + private int serverPort; + private String previousCacheMode; + + @BeforeClass + public static void setupClass() { + JMeterTestUtils.setupJmeterEnv(); + } + + @Before + public void setUp() throws Exception { + previousCacheMode = JMeterUtils.getProperty("cache_manager.cached_resource_mode"); + JMeterUtils.setProperty("cache_manager.cached_resource_mode", "RETURN_200_CACHE"); + JMeterUtils.setProperty("RETURN_200_CACHE.message", "cached"); + + server = new ServerBuilder().withHTTP1().withSSL().buildServer(); + server.start(); + serverPort = ((ServerConnector) server.getConnectors()[0]).getLocalPort(); + client = HttpClient4PluginParitySupport.newHttp1PluginClient("cache-parity"); + } + + @After + public void tearDown() throws Exception { + if (previousCacheMode != null) { + JMeterUtils.setProperty("cache_manager.cached_resource_mode", previousCacheMode); + } + if (client != null) { + client.stop(); + } + if (server != null) { + server.stop(); + } + } + + @Test + public void secondEmbeddedFetchUsesCacheLikeHttpClient4() throws Exception { + CacheManager cacheManager = new CacheManager(); + cacheManager.setUseExpires(true); + cacheManager.setClearEachIteration(false); + cacheManager.testIterationStart(null); + + HTTP2Sampler sampler = new HTTP2Sampler(); + sampler.setMethod(HTTPConstants.GET); + sampler.setProtocol("https"); + sampler.setDomain(ServerBuilder.HOST_NAME); + sampler.setPort(serverPort); + sampler.setPath(ServerBuilder.SERVER_PATH_200_EMBEDDED); + sampler.setImageParser(true); // embedded resource download + sampler.setUseKeepAlive(true); + sampler.setCacheManager(cacheManager); + + URL url = new URL("https", ServerBuilder.HOST_NAME, serverPort, + ServerBuilder.SERVER_PATH_200_EMBEDDED); + + HttpClient4PluginParitySupport.sampleHttpClient4(sampler, url); + HTTPSampleResult refSecond = HttpClient4PluginParitySupport.sampleHttpClient4(sampler, url); + + HttpClient4PluginParitySupport.samplePlugin(client, sampler, url); + HTTPSampleResult pluginSecond = HttpClient4PluginParitySupport.samplePlugin(client, sampler, url); + + HttpClient4PluginParitySupport.assertCoreParity(refSecond, pluginSecond, "cached embedded"); + assertThat(refSecond.getSubResults()).hasSize(pluginSecond.getSubResults().length); + if (refSecond.getSubResults().length > 0) { + assertThat(pluginSecond.getSubResults()[0].getResponseMessage()) + .isEqualTo(refSecond.getSubResults()[0].getResponseMessage()); + } + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/parity/HttpClient4PluginParitySupport.java b/src/test/java/com/blazemeter/jmeter/http2/parity/HttpClient4PluginParitySupport.java new file mode 100644 index 0000000..6117b0b --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/parity/HttpClient4PluginParitySupport.java @@ -0,0 +1,107 @@ +package com.blazemeter.jmeter.http2.parity; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.blazemeter.jmeter.http2.core.HTTP2ClientProfileConfig; +import com.blazemeter.jmeter.http2.core.HTTP2JettyClient; +import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler; +import java.lang.reflect.Method; +import java.net.URL; +import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; +import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase; +import org.apache.jmeter.protocol.http.sampler.HTTPSamplerFactory; +import org.apache.jmeter.util.JMeterUtils; + +/** Runs the same sampler configuration through HttpClient4 and the BlazeMeter HTTP Jetty client. */ +public final class HttpClient4PluginParitySupport { + + private static final String HTTP_CLIENT4 = "HttpClient4"; + + private HttpClient4PluginParitySupport() { + } + + public static void forceHttp1ClientProfile() { + JMeterUtils.setProperty("httpJettyClient.enableHttp1", "true"); + JMeterUtils.setProperty("httpJettyClient.enableHttp2", "false"); + JMeterUtils.setProperty("httpJettyClient.enableHttp3", "false"); + JMeterUtils.setProperty("httpJettyClient.alpnEnabled", "false"); + } + + public static HTTP2JettyClient newHttp1PluginClient(String name) throws Exception { + forceHttp1ClientProfile(); + HTTP2JettyClient client = new HTTP2JettyClient(false, name, + HTTP2ClientProfileConfig.builder().enableHttp1(true).enableHttp2(false).enableHttp3(false) + .build()); + client.start(); + return client; + } + + public static void copyHttpSamplerConfig(HTTPSamplerBase from, HTTPSamplerBase to) { + to.setDomain(from.getDomain()); + to.setPort(from.getPort()); + to.setProtocol(from.getProtocol()); + to.setPath(from.getPath()); + to.setMethod(from.getMethod()); + to.setFollowRedirects(from.getFollowRedirects()); + to.setAutoRedirects(from.getAutoRedirects()); + to.setUseKeepAlive(from.getUseKeepAlive()); + to.setDoMultipart(from.getDoMultipart()); + to.setPostBodyRaw(from.getPostBodyRaw()); + to.setContentEncoding(from.getContentEncoding()); + to.setArguments(from.getArguments()); + to.setHTTPFiles(from.getHTTPFiles()); + to.setCookieManager(from.getCookieManager()); + to.setCacheManager(from.getCacheManager()); + to.setHeaderManager(from.getHeaderManager()); + to.setAuthManager(from.getAuthManager()); + to.setImageParser(from.isImageParser()); + } + + public static HTTPSampleResult sampleHttpClient4(HTTP2Sampler sampler, URL url) + throws Exception { + HTTPSamplerBase hc4 = HTTPSamplerFactory.newInstance(HTTP_CLIENT4); + copyHttpSamplerConfig(sampler, hc4); + Method sample = HTTPSamplerBase.class.getDeclaredMethod( + "sample", URL.class, String.class, boolean.class, int.class); + sample.setAccessible(true); + return (HTTPSampleResult) sample.invoke( + hc4, url, sampler.getMethod(), sampler.getFollowRedirects(), 1); + } + + public static HTTPSampleResult samplePlugin(HTTP2JettyClient client, HTTP2Sampler sampler, + URL url) throws Exception { + HTTPSampleResult shell = new HTTPSampleResult(); + shell.setURL(url); + shell.setHTTPMethod(sampler.getMethod()); + shell.setSampleLabel(sampler.getName()); + client.loadProperties(); + return client.sample(sampler, shell, false, 0); + } + + public static void assertCoreParity(HTTPSampleResult reference, HTTPSampleResult plugin, + String context) { + assertThat(plugin.isSuccessful()) + .as("%s success", context) + .isEqualTo(reference.isSuccessful()); + assertThat(plugin.getResponseCode()) + .as("%s response code", context) + .isEqualTo(reference.getResponseCode()); + assertThat(normalizeMessage(plugin.getResponseMessage())) + .as("%s response message", context) + .isEqualTo(normalizeMessage(reference.getResponseMessage())); + assertThat(plugin.getRedirectLocation()) + .as("%s redirect location", context) + .isEqualTo(reference.getRedirectLocation()); + } + + public static void assertResponseBodyParity(HTTPSampleResult reference, HTTPSampleResult plugin, + String context) { + assertThat(plugin.getResponseDataAsString()) + .as("%s response body", context) + .isEqualTo(reference.getResponseDataAsString()); + } + + private static String normalizeMessage(String message) { + return message == null ? "" : message.trim(); + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/parity/HttpCookieManagerParityTest.java b/src/test/java/com/blazemeter/jmeter/http2/parity/HttpCookieManagerParityTest.java new file mode 100644 index 0000000..0d4fe7a --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/parity/HttpCookieManagerParityTest.java @@ -0,0 +1,85 @@ +package com.blazemeter.jmeter.http2.parity; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import com.blazemeter.jmeter.http2.core.HTTP2JettyClient; +import com.blazemeter.jmeter.http2.core.ServerBuilder; +import com.blazemeter.jmeter.http2.core.ServerBuilder.TeardownableServer; +import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler; +import com.blazemeter.jmeter.http2.sampler.JMeterTestUtils; +import java.net.URL; +import org.apache.jmeter.protocol.http.control.CookieManager; +import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.eclipse.jetty.server.ServerConnector; +import org.junit.After; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +/** Port of cookie jar behaviour covered by Apache {@code TestHC4CookieManager} scenarios. */ +public class HttpCookieManagerParityTest extends HTTP2TestBase { + + private TeardownableServer server; + private HTTP2JettyClient client; + private int serverPort; + + @BeforeClass + public static void setupClass() { + JMeterTestUtils.setupJmeterEnv(); + } + + @Before + public void setUp() throws Exception { + server = new ServerBuilder().withHTTP1().withSSL().buildServer(); + server.start(); + serverPort = ((ServerConnector) server.getConnectors()[0]).getLocalPort(); + client = HttpClient4PluginParitySupport.newHttp1PluginClient("cookie-parity"); + } + + @After + public void tearDown() throws Exception { + if (client != null) { + client.stop(); + } + if (server != null) { + server.stop(); + } + } + + @Test + public void setCookieAndEchoMatchesHttpClient4() throws Exception { + CookieManager cookieManager = new CookieManager(); + cookieManager.testStarted(ServerBuilder.HOST_NAME); + + HTTP2Sampler setCookies = baseSampler(ServerBuilder.SERVER_PATH_SET_COOKIES); + setCookies.setCookieManager(cookieManager); + URL setUrl = new URL("https", ServerBuilder.HOST_NAME, serverPort, + ServerBuilder.SERVER_PATH_SET_COOKIES); + HttpClient4PluginParitySupport.assertCoreParity( + HttpClient4PluginParitySupport.sampleHttpClient4(setCookies, setUrl), + HttpClient4PluginParitySupport.samplePlugin(client, setCookies, setUrl), + "set cookies"); + + HTTP2Sampler useCookies = baseSampler(ServerBuilder.SERVER_PATH_USE_COOKIES); + useCookies.setCookieManager(cookieManager); + URL useUrl = new URL("https", ServerBuilder.HOST_NAME, serverPort, + ServerBuilder.SERVER_PATH_USE_COOKIES); + HTTPSampleResult reference = HttpClient4PluginParitySupport.sampleHttpClient4(useCookies, useUrl); + HTTPSampleResult plugin = HttpClient4PluginParitySupport.samplePlugin(client, useCookies, useUrl); + + HttpClient4PluginParitySupport.assertCoreParity(reference, plugin, "echo cookies"); + HttpClient4PluginParitySupport.assertResponseBodyParity(reference, plugin, "cookie body"); + } + + private HTTP2Sampler baseSampler(String path) { + HTTP2Sampler sampler = new HTTP2Sampler(); + sampler.setMethod(HTTPConstants.GET); + sampler.setProtocol("https"); + sampler.setDomain(ServerBuilder.HOST_NAME); + sampler.setPort(serverPort); + sampler.setPath(path); + sampler.setUseKeepAlive(true); + sampler.setFollowRedirects(true); + return sampler; + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/parity/HttpDisableArgumentsParityTest.java b/src/test/java/com/blazemeter/jmeter/http2/parity/HttpDisableArgumentsParityTest.java new file mode 100644 index 0000000..76a969a --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/parity/HttpDisableArgumentsParityTest.java @@ -0,0 +1,87 @@ +package com.blazemeter.jmeter.http2.parity; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import com.blazemeter.jmeter.http2.core.HTTP2JettyClient; +import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler; +import com.blazemeter.jmeter.http2.sampler.JMeterTestUtils; +import java.net.URL; +import org.apache.jmeter.config.Arguments; +import org.apache.jmeter.protocol.http.control.HttpMirrorServer; +import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; +import org.apache.jmeter.protocol.http.util.HTTPArgument; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.junit.After; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Parity for skippable HTTP arguments (blank name / unresolved variable) as in JMeter 5.6.3. + * Per-argument {@code enabled} checkbox parity targets JMeter 5.7+ ({@code HttpSamplerDisableArgumentsTest}). + */ +public class HttpDisableArgumentsParityTest extends HTTP2TestBase { + + private HttpMirrorServer mirrorServer; + private HTTP2JettyClient client; + private int mirrorPort; + + @BeforeClass + public static void setupClass() { + JMeterTestUtils.setupJmeterEnv(); + } + + @Before + public void setUp() throws Exception { + mirrorPort = findFreePort(); + mirrorServer = new HttpMirrorServer(mirrorPort, 10, 10); + mirrorServer.start(); + client = HttpClient4PluginParitySupport.newHttp1PluginClient("disable-args-parity"); + } + + @After + public void tearDown() throws Exception { + if (client != null) { + client.stop(); + } + if (mirrorServer != null) { + mirrorServer.stopServer(); + } + } + + @Test + public void skippableArgumentsAreOmittedLikeHttpClient4() throws Exception { + HTTP2Sampler sampler = new HTTP2Sampler(); + sampler.setMethod(HTTPConstants.POST); + sampler.setProtocol("http"); + sampler.setDomain("localhost"); + sampler.setPort(mirrorPort); + sampler.setPath("/mirror-args"); + sampler.setUseKeepAlive(true); + + Arguments args = new Arguments(); + args.addArgument(new HTTPArgument("keep", "yes", false)); + args.addArgument(new HTTPArgument("", "blank-name", false)); + args.addArgument(new HTTPArgument("${optionalVar}", "unresolved", false)); + sampler.setArguments(args); + + URL url = new URL("http", "localhost", mirrorPort, "/mirror-args"); + HTTPSampleResult reference = HttpClient4PluginParitySupport.sampleHttpClient4(sampler, url); + HTTPSampleResult plugin = HttpClient4PluginParitySupport.samplePlugin(client, sampler, url); + + HttpClient4PluginParitySupport.assertCoreParity(reference, plugin, "skippable args"); + assertThat(reference.getResponseDataAsString()).contains("keep=yes"); + assertThat(plugin.getResponseDataAsString()).contains("keep=yes"); + assertThat(reference.getResponseDataAsString()).doesNotContain("blank-name"); + assertThat(plugin.getResponseDataAsString()).doesNotContain("blank-name"); + assertThat(reference.getResponseDataAsString()).doesNotContain("unresolved"); + assertThat(plugin.getResponseDataAsString()).doesNotContain("unresolved"); + } + + private static int findFreePort() throws Exception { + try (java.net.ServerSocket socket = new java.net.ServerSocket(0)) { + return socket.getLocalPort(); + } + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/parity/HttpMirrorFileUploadParityTest.java b/src/test/java/com/blazemeter/jmeter/http2/parity/HttpMirrorFileUploadParityTest.java new file mode 100644 index 0000000..9c60ab1 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/parity/HttpMirrorFileUploadParityTest.java @@ -0,0 +1,127 @@ +package com.blazemeter.jmeter.http2.parity; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import com.blazemeter.jmeter.http2.core.HTTP2JettyClient; +import com.blazemeter.jmeter.http2.parity.HttpMirrorParitySupport.MirrorParityResult; +import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler; +import com.blazemeter.jmeter.http2.sampler.JMeterTestUtils; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.Collection; +import org.apache.jmeter.protocol.http.control.HttpMirrorServer; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +/** Port of Apache {@code testPostRequest_FileUpload} scenarios (items 0–2). */ +@RunWith(Parameterized.class) +public class HttpMirrorFileUploadParityTest extends HTTP2TestBase { + + private static final String TITLE = "title"; + private static final String DESCRIPTION = "description"; + private static final String ISO_8859_1 = "ISO-8859-1"; + private static final byte[] UPLOAD_BYTES = + "some foo content &?=01234+56789-|œ♪".getBytes(StandardCharsets.UTF_8); + + private static File uploadFile; + + @Parameterized.Parameter + public int item; + + private HttpMirrorServer mirrorServer; + private HTTP2JettyClient client; + private int mirrorPort; + + @Parameterized.Parameters(name = "file-upload-item-{0}") + public static Collection data() { + return Arrays.asList(0, 1, 2); + } + + @BeforeClass + public static void setupClass() throws Exception { + JMeterTestUtils.setupJmeterEnv(); + uploadFile = Files.createTempFile("HttpMirrorFileUploadParityTest-", ".tmp").toFile(); + Files.write(uploadFile.toPath(), UPLOAD_BYTES); + uploadFile.deleteOnExit(); + } + + @AfterClass + public static void tearDownClass() { + if (uploadFile != null) { + uploadFile.delete(); + } + } + + @Before + public void setUp() throws Exception { + mirrorPort = findFreePort(); + mirrorServer = new HttpMirrorServer(mirrorPort, 10, 10); + mirrorServer.start(); + client = HttpClient4PluginParitySupport.newHttp1PluginClient("mirror-file-upload"); + } + + @After + public void tearDown() throws Exception { + if (client != null) { + client.stop(); + } + if (mirrorServer != null) { + mirrorServer.stopServer(); + } + } + + @Test + public void fileUploadEchoMatchesHttpClient4() throws Exception { + HTTP2Sampler sampler = buildFileUploadSampler(item); + MirrorParityResult result = HttpMirrorParitySupport.runMirrorParity( + client, sampler, "file upload item " + item); + result.assertEchoContains("name=\"file1\"", "filename="); + result.assertMultipartFieldValuesMatch(TITLE); + result.assertMultipartFieldValuesMatch(DESCRIPTION); + result.assertEchoContains("some foo content"); + } + + private HTTP2Sampler buildFileUploadSampler(int test) { + String titleValue = "mytitle"; + String descriptionValue = "mydescription"; + String contentEncoding = ""; + + switch (test) { + case 0: + break; + case 1: + contentEncoding = ISO_8859_1; + break; + case 2: + contentEncoding = "UTF-8"; + titleValue = "mytitleœ₡ĕÅ"; + descriptionValue = "mydescriptionœ₡ĕÅ"; + break; + default: + throw new IllegalArgumentException("Unsupported file upload item: " + test); + } + + HTTP2Sampler sampler = HttpMirrorParitySupport.baseMirrorSampler(mirrorPort, HTTPConstants.POST); + sampler.setDoMultipart(true); + if (!contentEncoding.isEmpty()) { + sampler.setContentEncoding(contentEncoding); + } + HttpMirrorParitySupport.addFormPair(sampler, TITLE, titleValue, false); + HttpMirrorParitySupport.addFormPair(sampler, DESCRIPTION, descriptionValue, false); + HttpMirrorParitySupport.addFileUpload(sampler, "file1", uploadFile, "text/plain"); + return sampler; + } + + private static int findFreePort() throws Exception { + try (java.net.ServerSocket socket = new java.net.ServerSocket(0)) { + return socket.getLocalPort(); + } + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/parity/HttpMirrorItemisedParityTest.java b/src/test/java/com/blazemeter/jmeter/http2/parity/HttpMirrorItemisedParityTest.java new file mode 100644 index 0000000..0a4913c --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/parity/HttpMirrorItemisedParityTest.java @@ -0,0 +1,242 @@ +package com.blazemeter.jmeter.http2.parity; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import com.blazemeter.jmeter.http2.core.HTTP2JettyClient; +import com.blazemeter.jmeter.http2.parity.HttpMirrorParitySupport.MirrorParityResult; +import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler; +import com.blazemeter.jmeter.http2.sampler.JMeterTestUtils; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import org.apache.jmeter.protocol.http.control.HttpMirrorServer; +import org.apache.jmeter.protocol.http.util.HTTPArgument; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.junit.After; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +/** + * Port of Apache {@code itemised_testPostRequest_UrlEncoded} and + * {@code itemised_testGetRequest_Parameters} (cases 0–7 / 0–5). + */ +@RunWith(Parameterized.class) +public class HttpMirrorItemisedParityTest extends HTTP2TestBase { + + private static final String TITLE = "title"; + private static final String DESCRIPTION = "description"; + private static final String ISO_8859_1 = "ISO-8859-1"; + + @Parameterized.Parameter(0) + public String mode; + + @Parameterized.Parameter(1) + public int item; + + private HttpMirrorServer mirrorServer; + private HTTP2JettyClient client; + private int mirrorPort; + + @Parameterized.Parameters(name = "{0}-item-{1}") + public static Collection data() { + List rows = new ArrayList<>(); + for (int i = 0; i <= 7; i++) { + rows.add(new Object[] {"POST", i}); + } + for (int i = 0; i <= 5; i++) { + rows.add(new Object[] {"GET", i}); + } + return rows; + } + + @BeforeClass + public static void setupClass() { + JMeterTestUtils.setupJmeterEnv(); + } + + @Before + public void setUp() throws Exception { + mirrorPort = findFreePort(); + mirrorServer = new HttpMirrorServer(mirrorPort, 10, 10); + mirrorServer.start(); + client = HttpClient4PluginParitySupport.newHttp1PluginClient("mirror-itemised"); + } + + @After + public void tearDown() throws Exception { + HttpMirrorParitySupport.clearMirrorVariables(); + if (client != null) { + client.stop(); + } + if (mirrorServer != null) { + mirrorServer.stopServer(); + } + } + + @Test + public void mirrorEchoMatchesHttpClient4() throws Exception { + HTTP2Sampler sampler = buildSampler(); + String context = mode + " mirror item " + item; + MirrorParityResult result = HttpMirrorParitySupport.runMirrorParity(client, sampler, context); + if ("GET".equals(mode)) { + result.assertRequestLineMatches(); + } else { + result.assertPostBodyMatches(); + } + } + + private HTTP2Sampler buildSampler() throws Exception { + if ("GET".equals(mode)) { + return buildGetParametersSampler(item); + } + return buildPostUrlEncodedSampler(item); + } + + private HTTP2Sampler buildGetParametersSampler(int test) throws Exception { + String titleValue = "mytitle"; + String descriptionValue = "mydescription"; + String contentEncoding = ""; + boolean alwaysEncoded = false; + + switch (test) { + case 0: + break; + case 1: + contentEncoding = ISO_8859_1; + titleValue = "mytitle1Œ"; + descriptionValue = "mydescription1Œ"; + break; + case 2: + contentEncoding = "UTF-8"; + titleValue = "mytitle2œ₡ĕÅ"; + descriptionValue = "mydescription2œ₡ĕÅ"; + break; + case 3: + contentEncoding = "UTF-8"; + titleValue = "mytitle3œ+₡ ĕ&yesÅ"; + descriptionValue = "mydescription3 œ ₡ ĕ Å"; + break; + case 4: + contentEncoding = "UTF-8"; + titleValue = "mytitle4%2F%3D"; + descriptionValue = "mydescription4+++%2F%5C"; + alwaysEncoded = true; + break; + case 5: + return buildGetWithVariablesSampler(); + default: + throw new IllegalArgumentException("Unsupported GET item: " + test); + } + + HTTP2Sampler sampler = baseGetSampler(contentEncoding); + HttpMirrorParitySupport.addFormPair(sampler, TITLE, titleValue, alwaysEncoded); + HttpMirrorParitySupport.addFormPair(sampler, DESCRIPTION, descriptionValue, alwaysEncoded); + return sampler; + } + + private HTTP2Sampler buildGetWithVariablesSampler() throws Exception { + HttpMirrorParitySupport.setupMirrorVariables(); + HTTP2Sampler sampler = baseGetSampler("UTF-8"); + HttpMirrorParitySupport.addFormPair(sampler, TITLE, + "${title_prefix}mytitle5œ₡ĕÅ", false); + HttpMirrorParitySupport.addFormPair(sampler, DESCRIPTION, + "mydescription5œ₡ĕÅ${description_suffix}", false); + HttpMirrorParitySupport.replaceSamplerVariables(sampler); + return sampler; + } + + private HTTP2Sampler buildPostUrlEncodedSampler(int test) throws Exception { + String titleValue = "mytitle"; + String descriptionValue = "mydescription"; + String contentEncoding = ""; + boolean alwaysEncoded = false; + + switch (test) { + case 0: + break; + case 1: + contentEncoding = ISO_8859_1; + break; + case 2: + contentEncoding = "UTF-8"; + titleValue = "mytitle2œ₡ĕÅ"; + descriptionValue = "mydescription2œ₡ĕÅ"; + break; + case 3: + contentEncoding = "UTF-8"; + titleValue = "mytitle3/="; + descriptionValue = "mydescription3 /\\"; + break; + case 4: + contentEncoding = "UTF-8"; + titleValue = "mytitle4%2F%3D"; + descriptionValue = "mydescription4+++%2F%5C"; + alwaysEncoded = true; + break; + case 5: + contentEncoding = "UTF-8"; + titleValue = "/wEPDwULLTE2MzM2OTA0NTYPZBYCAgMPZ/rA+8DZ2dnZ2dnZ2d/GNDar6OshPwdJc="; + descriptionValue = "mydescription5"; + break; + case 6: + contentEncoding = "UTF-8"; + titleValue = "%2FwEPDwULLTE2MzM2OTA0NTYPZBYCAgMPZ%2FrA%2B8DZ2dnZ2dnZ2d%2FGNDar6OshPwdJc%3D"; + descriptionValue = "mydescription6"; + return buildPostProxyStyleSampler(contentEncoding, titleValue, descriptionValue); + case 7: + return buildPostWithVariablesSampler(); + default: + throw new IllegalArgumentException("Unsupported POST item: " + test); + } + + HTTP2Sampler sampler = basePostSampler(contentEncoding); + HttpMirrorParitySupport.addFormPair(sampler, TITLE, titleValue, alwaysEncoded); + HttpMirrorParitySupport.addFormPair(sampler, DESCRIPTION, descriptionValue, alwaysEncoded); + return sampler; + } + + private HTTP2Sampler buildPostProxyStyleSampler(String contentEncoding, String titleValue, + String descriptionValue) { + HTTP2Sampler sampler = basePostSampler(contentEncoding); + HttpMirrorParitySupport.addFormPair(sampler, TITLE, titleValue, false); + HttpMirrorParitySupport.addFormPair(sampler, DESCRIPTION, descriptionValue, false); + ((HTTPArgument) sampler.getArguments().getArgument(0)).setAlwaysEncoded(false); + ((HTTPArgument) sampler.getArguments().getArgument(1)).setAlwaysEncoded(false); + return sampler; + } + + private HTTP2Sampler buildPostWithVariablesSampler() throws Exception { + HttpMirrorParitySupport.setupMirrorVariables(); + HTTP2Sampler sampler = basePostSampler("UTF-8"); + HttpMirrorParitySupport.addFormPair(sampler, TITLE, + "${title_prefix}mytitle7œ₡ĕÅ", false); + HttpMirrorParitySupport.addFormPair(sampler, DESCRIPTION, + "mydescription7œ₡ĕÅ${description_suffix}", false); + HttpMirrorParitySupport.replaceSamplerVariables(sampler); + return sampler; + } + + private HTTP2Sampler baseGetSampler(String contentEncoding) { + HTTP2Sampler sampler = HttpMirrorParitySupport.baseMirrorSampler(mirrorPort, HTTPConstants.GET); + if (!contentEncoding.isEmpty()) { + sampler.setContentEncoding(contentEncoding); + } + return sampler; + } + + private HTTP2Sampler basePostSampler(String contentEncoding) { + HTTP2Sampler sampler = HttpMirrorParitySupport.baseMirrorSampler(mirrorPort, HTTPConstants.POST); + if (!contentEncoding.isEmpty()) { + sampler.setContentEncoding(contentEncoding); + } + return sampler; + } + + private static int findFreePort() throws Exception { + try (java.net.ServerSocket socket = new java.net.ServerSocket(0)) { + return socket.getLocalPort(); + } + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/parity/HttpMirrorMultipartParityTest.java b/src/test/java/com/blazemeter/jmeter/http2/parity/HttpMirrorMultipartParityTest.java new file mode 100644 index 0000000..c22f0e2 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/parity/HttpMirrorMultipartParityTest.java @@ -0,0 +1,144 @@ +package com.blazemeter.jmeter.http2.parity; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import com.blazemeter.jmeter.http2.core.HTTP2JettyClient; +import com.blazemeter.jmeter.http2.parity.HttpMirrorParitySupport.MirrorParityResult; +import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler; +import com.blazemeter.jmeter.http2.sampler.JMeterTestUtils; +import java.util.Arrays; +import java.util.Collection; +import org.apache.jmeter.protocol.http.control.HttpMirrorServer; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.apache.jmeter.threads.JMeterContextService; +import org.junit.After; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +/** Port of Apache {@code testPostRequest_FormMultipart} scenarios (items 0–6). */ +@RunWith(Parameterized.class) +public class HttpMirrorMultipartParityTest extends HTTP2TestBase { + + private static final String TITLE = "title"; + private static final String DESCRIPTION = "description"; + private static final String ISO_8859_1 = "ISO-8859-1"; + + @Parameterized.Parameter + public int item; + + private HttpMirrorServer mirrorServer; + private HTTP2JettyClient client; + private int mirrorPort; + + @Parameterized.Parameters(name = "multipart-item-{0}") + public static Collection data() { + return Arrays.asList(0, 1, 2, 3, 4, 5, 6); + } + + @BeforeClass + public static void setupClass() { + JMeterTestUtils.setupJmeterEnv(); + } + + @Before + public void setUp() throws Exception { + mirrorPort = findFreePort(); + mirrorServer = new HttpMirrorServer(mirrorPort, 10, 10); + mirrorServer.start(); + client = HttpClient4PluginParitySupport.newHttp1PluginClient("mirror-multipart"); + } + + @After + public void tearDown() throws Exception { + HttpMirrorParitySupport.clearMirrorVariables(); + if (client != null) { + client.stop(); + } + if (mirrorServer != null) { + mirrorServer.stopServer(); + } + } + + @Test + public void multipartEchoMatchesHttpClient4() throws Exception { + HTTP2Sampler sampler = buildMultipartSampler(item); + MirrorParityResult result = HttpMirrorParitySupport.runMirrorParity( + client, sampler, "multipart item " + item); + result.assertEchoContains("Content-Transfer-Encoding: 8bit"); + result.assertMultipartFieldValuesMatch(TITLE); + result.assertMultipartFieldValuesMatch(DESCRIPTION); + } + + private HTTP2Sampler buildMultipartSampler(int test) throws Exception { + String titleValue = "mytitle"; + String descriptionValue = "mydescription"; + String contentEncoding = ""; + boolean alwaysEncoded = false; + + switch (test) { + case 0: + break; + case 1: + contentEncoding = ISO_8859_1; + break; + case 2: + contentEncoding = "UTF-8"; + titleValue = "mytitleœ₡ĕÅ"; + descriptionValue = "mydescriptionœ₡ĕÅ"; + break; + case 3: + contentEncoding = "UTF-8"; + titleValue = "mytitle/="; + descriptionValue = "mydescription /\\"; + break; + case 4: + contentEncoding = "UTF-8"; + titleValue = "mytitle%2F%3D"; + descriptionValue = "mydescription+++%2F%5C"; + alwaysEncoded = true; + break; + case 5: + contentEncoding = "UTF-8"; + titleValue = "/wEPDwULLTE2MzM2OTA0NTYPZBYCAgMPZ/rA+8DZ2dnZ2dnZ2d/GNDar6OshPwdJc="; + descriptionValue = "mydescription"; + break; + case 6: + return buildMultipartWithVariablesSampler(); + default: + throw new IllegalArgumentException("Unsupported multipart item: " + test); + } + + HTTP2Sampler sampler = baseMultipartSampler(contentEncoding); + HttpMirrorParitySupport.addFormPair(sampler, TITLE, titleValue, alwaysEncoded); + HttpMirrorParitySupport.addFormPair(sampler, DESCRIPTION, descriptionValue, alwaysEncoded); + return sampler; + } + + private HTTP2Sampler buildMultipartWithVariablesSampler() throws Exception { + HttpMirrorParitySupport.setupMirrorVariables(); + HTTP2Sampler sampler = baseMultipartSampler("UTF-8"); + HttpMirrorParitySupport.addFormPair(sampler, TITLE, + "${title_prefix}mytitleœ₡ĕÅ", false); + HttpMirrorParitySupport.addFormPair(sampler, DESCRIPTION, + "mydescriptionœ₡ĕÅ${description_suffix}", false); + HttpMirrorParitySupport.replaceSamplerVariables(sampler); + return sampler; + } + + private HTTP2Sampler baseMultipartSampler(String contentEncoding) { + HTTP2Sampler sampler = HttpMirrorParitySupport.baseMirrorSampler(mirrorPort, HTTPConstants.POST); + sampler.setDoMultipart(true); + if (!contentEncoding.isEmpty()) { + sampler.setContentEncoding(contentEncoding); + } + return sampler; + } + + private static int findFreePort() throws Exception { + try (java.net.ServerSocket socket = new java.net.ServerSocket(0)) { + return socket.getLocalPort(); + } + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/parity/HttpMirrorParitySupport.java b/src/test/java/com/blazemeter/jmeter/http2/parity/HttpMirrorParitySupport.java new file mode 100644 index 0000000..a02f874 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/parity/HttpMirrorParitySupport.java @@ -0,0 +1,239 @@ +package com.blazemeter.jmeter.http2.parity; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.blazemeter.jmeter.http2.core.HTTP2JettyClient; +import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import org.apache.jmeter.config.Arguments; +import org.apache.jmeter.engine.util.ValueReplacer; +import org.apache.jmeter.testelement.TestPlan; +import org.apache.jmeter.threads.JMeterContextService; +import org.apache.jmeter.threads.JMeterVariables; +import org.apache.jmeter.util.JMeterUtils; +import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; +import org.apache.jmeter.protocol.http.util.HTTPArgument; +import org.apache.jmeter.protocol.http.util.HTTPFileArg; + +/** Helpers for {@link HttpMirrorParityTest} (Apache mirror-server parity). */ +public final class HttpMirrorParitySupport { + + /** Default path used by {@code TestHTTPSamplersAgainstHttpMirrorServer}. */ + public static final String MIRROR_PATH = "/test/somescript.jsp"; + + private HttpMirrorParitySupport() { + } + + public static HTTP2Sampler baseMirrorSampler(int mirrorPort, String method) { + HTTP2Sampler sampler = new HTTP2Sampler(); + sampler.setMethod(method); + sampler.setProtocol("http"); + sampler.setDomain("localhost"); + sampler.setPort(mirrorPort); + sampler.setPath(MIRROR_PATH); + sampler.setUseKeepAlive(true); + sampler.setFollowRedirects(true); + return sampler; + } + + public static void addFormPair(HTTP2Sampler sampler, String name, String value, + boolean alwaysEncoded) { + if (alwaysEncoded) { + sampler.addEncodedArgument(name, value); + return; + } + Arguments args = sampler.getArguments(); + if (args == null) { + args = new Arguments(); + sampler.setArguments(args); + } + args.addArgument(new HTTPArgument(name, value, false)); + } + + public static void addRawBodyValues(HTTP2Sampler sampler, String... values) { + Arguments args = new Arguments(); + for (String value : values) { + HTTPArgument arg = new HTTPArgument("", value, false); + arg.setAlwaysEncoded(false); + args.addArgument(arg); + } + sampler.setArguments(args); + sampler.setPostBodyRaw(true); + } + + public static void addFileUpload(HTTP2Sampler sampler, String fieldName, java.io.File file, + String mimeType) { + sampler.setDoMultipart(true); + sampler.setHTTPFiles(new HTTPFileArg[] { + new HTTPFileArg(file.getAbsolutePath(), fieldName, mimeType) + }); + } + + public static MirrorParityResult runMirrorParity(HTTP2JettyClient client, HTTP2Sampler sampler, + String context) throws Exception { + URL url = sampler.getUrl(); + HTTPSampleResult reference = HttpClient4PluginParitySupport.sampleHttpClient4(sampler, url); + HTTPSampleResult plugin = HttpClient4PluginParitySupport.samplePlugin(client, sampler, url); + HttpClient4PluginParitySupport.assertCoreParity(reference, plugin, context); + return new MirrorParityResult(reference, plugin); + } + + public static String requestLine(HTTPSampleResult mirrorEcho) { + String echo = mirrorEcho.getResponseDataAsString().replace("\r\n", "\n"); + int newline = echo.indexOf('\n'); + return newline >= 0 ? echo.substring(0, newline) : echo; + } + + public static void assertSameRequestLine(HTTPSampleResult reference, HTTPSampleResult plugin) { + assertThat(requestLine(plugin)).isEqualTo(requestLine(reference)); + } + + public static void assertEchoContainsBoth(HTTPSampleResult reference, HTTPSampleResult plugin, + String... fragments) { + for (String fragment : fragments) { + assertThat(reference.getResponseDataAsString()).as("reference echo").contains(fragment); + assertThat(plugin.getResponseDataAsString()).as("plugin echo").contains(fragment); + } + } + + public static void assertPostBodyContainsBoth(HTTPSampleResult reference, HTTPSampleResult plugin, + String bodyFragment) { + String refBody = bodyAfterHeaders(reference.getResponseDataAsString()); + String pluginBody = bodyAfterHeaders(plugin.getResponseDataAsString()); + assertThat(refBody).contains(bodyFragment); + assertThat(pluginBody).contains(bodyFragment); + } + + static String bodyAfterHeaders(String echo) { + String normalized = echo.replace("\r\n", "\n"); + int divider = normalized.indexOf("\n\n"); + return divider >= 0 ? normalized.substring(divider + 2) : normalized; + } + + static String multipartFieldValue(String multipartBody, String fieldName) { + String normalized = multipartBody.replace("\r\n", "\n"); + String marker = "name=\"" + fieldName + "\""; + int fieldStart = normalized.indexOf(marker); + if (fieldStart < 0) { + return ""; + } + int transferEncoding = normalized.indexOf("Content-Transfer-Encoding: 8bit", fieldStart); + int searchFrom = transferEncoding >= 0 ? transferEncoding : fieldStart; + int lineEnd = normalized.indexOf('\n', searchFrom); + if (lineEnd < 0) { + return ""; + } + int valueStart = lineEnd + 1; + if (valueStart < normalized.length() && normalized.charAt(valueStart) == '\n') { + valueStart++; + } + int valueEnd = normalized.indexOf("\n--", valueStart); + String value = valueEnd >= 0 ? normalized.substring(valueStart, valueEnd) + : normalized.substring(valueStart); + return value.replace("\n", "").trim(); + } + + static Map parseUrlEncodedBody(String body) { + Map fields = new LinkedHashMap<>(); + if (body == null || body.isEmpty()) { + return fields; + } + for (String pair : body.split("&")) { + int eq = pair.indexOf('='); + if (eq <= 0) { + continue; + } + String name = urlDecodeComponent(pair.substring(0, eq)); + String value = urlDecodeComponent(pair.substring(eq + 1)); + fields.put(name, value); + } + return fields; + } + + private static String urlDecodeComponent(String component) { + return URLDecoder.decode(component, StandardCharsets.UTF_8); + } + + public static void setupMirrorVariables() { + JMeterUtils.setLocale(Locale.ENGLISH); + JMeterVariables vars = new JMeterVariables(); + vars.put("title_prefix", "a testÅ"); + vars.put("description_suffix", "the_end"); + JMeterContextService.getContext().setVariables(vars); + JMeterContextService.getContext().setSamplingStarted(true); + } + + public static void clearMirrorVariables() { + JMeterContextService.getContext().setVariables(null); + JMeterContextService.getContext().setSamplingStarted(false); + } + + public static void replaceSamplerVariables(HTTP2Sampler sampler) throws Exception { + ValueReplacer replacer = new ValueReplacer(); + replacer.setUserDefinedVariables(new TestPlan().getUserDefinedVariables()); + replacer.replaceValues(sampler); + } + + public static void assertRawPostBodiesMatch(HTTPSampleResult reference, HTTPSampleResult plugin) { + String refBody = bodyAfterHeaders(reference.getResponseDataAsString()); + String pluginBody = bodyAfterHeaders(plugin.getResponseDataAsString()); + assertThat(pluginBody).as("raw POST body echo").isEqualTo(refBody); + } + + public static void assertUrlEncodedBodiesMatch(HTTPSampleResult reference, + HTTPSampleResult plugin) { + String refBody = bodyAfterHeaders(reference.getResponseDataAsString()); + String pluginBody = bodyAfterHeaders(plugin.getResponseDataAsString()); + assertThat(parseUrlEncodedBody(pluginBody)) + .as("urlencoded POST body") + .isEqualTo(parseUrlEncodedBody(refBody)); + } + + public static void assertMultipartFieldValuesMatch(HTTPSampleResult reference, + HTTPSampleResult plugin, String fieldName) { + String refBody = bodyAfterHeaders(reference.getResponseDataAsString()); + String pluginBody = bodyAfterHeaders(plugin.getResponseDataAsString()); + assertThat(multipartFieldValue(pluginBody, fieldName)) + .as("multipart field %s", fieldName) + .isEqualTo(multipartFieldValue(refBody, fieldName)); + } + + public static URL mirrorUrl(int port) throws Exception { + return new URL("http", "localhost", port, MIRROR_PATH); + } + + public static URL mirrorUrl(int port, String path) throws Exception { + return new URL("http", "localhost", port, path); + } + + public record MirrorParityResult(HTTPSampleResult reference, HTTPSampleResult plugin) { + public void assertRequestLineMatches() { + assertSameRequestLine(reference, plugin); + } + + public void assertEchoContains(String... fragments) { + assertEchoContainsBoth(reference, plugin, fragments); + } + + public void assertPostBodyContains(String fragment) { + assertPostBodyContainsBoth(reference, plugin, fragment); + } + + public void assertMultipartFieldValuesMatch(String fieldName) { + HttpMirrorParitySupport.assertMultipartFieldValuesMatch(reference, plugin, fieldName); + } + + public void assertPostBodyMatches() { + HttpMirrorParitySupport.assertUrlEncodedBodiesMatch(reference, plugin); + } + + public void assertRawPostBodyMatches() { + HttpMirrorParitySupport.assertRawPostBodiesMatch(reference, plugin); + } + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/parity/HttpMirrorParityTest.java b/src/test/java/com/blazemeter/jmeter/http2/parity/HttpMirrorParityTest.java new file mode 100644 index 0000000..1568814 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/parity/HttpMirrorParityTest.java @@ -0,0 +1,226 @@ +package com.blazemeter.jmeter.http2.parity; + +import static com.blazemeter.jmeter.http2.parity.HttpMirrorParitySupport.MIRROR_PATH; + +import com.blazemeter.jmeter.http2.parity.HttpMirrorParitySupport.MirrorParityResult; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import com.blazemeter.jmeter.http2.core.HTTP2JettyClient; +import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler; +import com.blazemeter.jmeter.http2.sampler.JMeterTestUtils; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import org.apache.jmeter.protocol.http.control.HttpMirrorServer; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Port of scenarios from Apache JMeter {@code TestHTTPSamplersAgainstHttpMirrorServer}. + * Compares HttpClient4 vs BlazeMeter HTTP on {@link HttpMirrorServer} echo (HTTP/1.1). + */ +public class HttpMirrorParityTest extends HTTP2TestBase { + + private static final String TITLE = "title"; + private static final String DESCRIPTION = "description"; + private static final byte[] UPLOAD_BYTES = + "some foo content &?=01234+56789-|œ♪".getBytes(StandardCharsets.UTF_8); + + private static File uploadFile; + + private HttpMirrorServer mirrorServer; + private HTTP2JettyClient client; + private int mirrorPort; + + @BeforeClass + public static void setupClass() throws Exception { + JMeterTestUtils.setupJmeterEnv(); + uploadFile = Files.createTempFile("HttpMirrorParityTest-", ".tmp").toFile(); + Files.write(uploadFile.toPath(), UPLOAD_BYTES); + uploadFile.deleteOnExit(); + } + + @AfterClass + public static void tearDownClass() { + if (uploadFile != null) { + uploadFile.delete(); + } + } + + @Before + public void setUp() throws Exception { + mirrorPort = findFreePort(); + mirrorServer = new HttpMirrorServer(mirrorPort, 10, 10); + mirrorServer.start(); + client = HttpClient4PluginParitySupport.newHttp1PluginClient("mirror-parity"); + } + + @After + public void tearDown() throws Exception { + if (client != null) { + client.stop(); + } + if (mirrorServer != null) { + mirrorServer.stopServer(); + } + } + + @Test + public void getWithIso88591EncodingMatchesHttpClient4() throws Exception { + HTTP2Sampler sampler = HttpMirrorParitySupport.baseMirrorSampler(mirrorPort, HTTPConstants.GET); + sampler.setContentEncoding("ISO-8859-1"); + MirrorParityResult result = HttpMirrorParitySupport.runMirrorParity( + client, sampler, "GET ISO-8859-1"); + result.assertRequestLineMatches(); + } + + @Test + public void getRequestEchoMatchesHttpClient4() throws Exception { + HTTP2Sampler sampler = HttpMirrorParitySupport.baseMirrorSampler(mirrorPort, HTTPConstants.GET); + MirrorParityResult result = HttpMirrorParitySupport.runMirrorParity( + client, sampler, "GET mirror"); + result.assertRequestLineMatches(); + result.assertEchoContains("GET " + MIRROR_PATH + " HTTP/1.1"); + } + + @Test + public void getWithQueryParametersMatchesHttpClient4() throws Exception { + HTTP2Sampler sampler = HttpMirrorParitySupport.baseMirrorSampler(mirrorPort, HTTPConstants.GET); + HttpMirrorParitySupport.addFormPair(sampler, TITLE, "mytitle", false); + HttpMirrorParitySupport.addFormPair(sampler, DESCRIPTION, "mydescription", false); + + MirrorParityResult result = HttpMirrorParitySupport.runMirrorParity( + client, sampler, "GET query params"); + result.assertRequestLineMatches(); + result.assertEchoContains("title=mytitle", "description=mydescription"); + } + + @Test + public void getWithUtf8SpecialCharactersMatchesHttpClient4() throws Exception { + HTTP2Sampler sampler = HttpMirrorParitySupport.baseMirrorSampler(mirrorPort, HTTPConstants.GET); + sampler.setContentEncoding("UTF-8"); + String titleValue = "mytitle3œ+♪ ĕ&yesÅ"; + String descriptionValue = "mydescription3 œ ♪ ĕ Å"; + HttpMirrorParitySupport.addFormPair(sampler, TITLE, titleValue, false); + HttpMirrorParitySupport.addFormPair(sampler, DESCRIPTION, descriptionValue, false); + + MirrorParityResult result = HttpMirrorParitySupport.runMirrorParity( + client, sampler, "GET UTF-8 params"); + result.assertRequestLineMatches(); + } + + @Test + public void getWithAlwaysEncodedParametersMatchesHttpClient4() throws Exception { + HTTP2Sampler sampler = HttpMirrorParitySupport.baseMirrorSampler(mirrorPort, HTTPConstants.GET); + sampler.setContentEncoding("UTF-8"); + HttpMirrorParitySupport.addFormPair(sampler, TITLE, "mytitle4%2F%3D", true); + HttpMirrorParitySupport.addFormPair(sampler, DESCRIPTION, "mydescription4+++%2F%5C", true); + + MirrorParityResult result = HttpMirrorParitySupport.runMirrorParity( + client, sampler, "GET encoded params"); + result.assertRequestLineMatches(); + result.assertEchoContains("title=mytitle4%2F%3D", "description=mydescription4+++%2F%5C"); + } + + @Test + public void postUrlEncodedEchoMatchesHttpClient4() throws Exception { + HTTP2Sampler sampler = HttpMirrorParitySupport.baseMirrorSampler(mirrorPort, HTTPConstants.POST); + HttpMirrorParitySupport.addFormPair(sampler, TITLE, "mytitle", false); + HttpMirrorParitySupport.addFormPair(sampler, DESCRIPTION, "mydescription", false); + + MirrorParityResult result = HttpMirrorParitySupport.runMirrorParity( + client, sampler, "POST urlencoded"); + result.assertPostBodyContains("title=mytitle"); + result.assertPostBodyContains("description=mydescription"); + } + + @Test + public void postUrlEncodedUtf8MatchesHttpClient4() throws Exception { + HTTP2Sampler sampler = HttpMirrorParitySupport.baseMirrorSampler(mirrorPort, HTTPConstants.POST); + sampler.setContentEncoding("UTF-8"); + HttpMirrorParitySupport.addFormPair(sampler, TITLE, "mytitleœ♪ĕÅ", false); + HttpMirrorParitySupport.addFormPair(sampler, DESCRIPTION, "mydescriptionœ♪ĕÅ", false); + + MirrorParityResult result = HttpMirrorParitySupport.runMirrorParity( + client, sampler, "POST UTF-8"); + result.assertPostBodyContains("title="); + result.assertPostBodyContains("description="); + } + + @Test + public void postMultipartEchoMatchesHttpClient4() throws Exception { + HTTP2Sampler sampler = HttpMirrorParitySupport.baseMirrorSampler(mirrorPort, HTTPConstants.POST); + sampler.setDoMultipart(true); + HttpMirrorParitySupport.addFormPair(sampler, "name1", "value1", false); + + sampler.setPath(MIRROR_PATH + "?name0=value0"); + MirrorParityResult result = HttpMirrorParitySupport.runMirrorParity( + client, sampler, "POST multipart"); + result.assertEchoContains("Content-Transfer-Encoding: 8bit", "name=\"name1\"", "value1"); + } + + @Test + public void postMultipartUtf8SpecialCharsMatchesHttpClient4() throws Exception { + HTTP2Sampler sampler = HttpMirrorParitySupport.baseMirrorSampler(mirrorPort, HTTPConstants.POST); + sampler.setContentEncoding("UTF-8"); + sampler.setDoMultipart(true); + HttpMirrorParitySupport.addFormPair(sampler, TITLE, "mytitle/=", false); + HttpMirrorParitySupport.addFormPair(sampler, DESCRIPTION, "mydescription /\\", false); + + MirrorParityResult result = HttpMirrorParitySupport.runMirrorParity( + client, sampler, "POST multipart UTF-8"); + result.assertEchoContains("name=\"" + TITLE + "\"", "name=\"" + DESCRIPTION + "\""); + result.assertMultipartFieldValuesMatch(TITLE); + result.assertMultipartFieldValuesMatch(DESCRIPTION); + } + + @Test + public void postFileUploadMatchesHttpClient4() throws Exception { + HTTP2Sampler sampler = HttpMirrorParitySupport.baseMirrorSampler(mirrorPort, HTTPConstants.POST); + sampler.setDoMultipart(true); + HttpMirrorParitySupport.addFormPair(sampler, TITLE, "mytitle", false); + HttpMirrorParitySupport.addFormPair(sampler, DESCRIPTION, "mydescription", false); + HttpMirrorParitySupport.addFileUpload(sampler, "file1", uploadFile, "text/plain"); + + MirrorParityResult result = HttpMirrorParitySupport.runMirrorParity( + client, sampler, "POST file upload"); + result.assertEchoContains("name=\"file1\"", "filename=", "some foo content"); + } + + @Test + public void postBodyRawFromParameterValuesMatchesHttpClient4() throws Exception { + HTTP2Sampler sampler = HttpMirrorParitySupport.baseMirrorSampler(mirrorPort, HTTPConstants.POST); + HttpMirrorParitySupport.addRawBodyValues(sampler, "mytitle", "mydescription"); + + MirrorParityResult result = HttpMirrorParitySupport.runMirrorParity( + client, sampler, "POST body raw"); + result.assertPostBodyContains("mytitlemydescription"); + } + + @Test + public void putWithFormBodyMatchesHttpClient4() throws Exception { + HTTP2Sampler sampler = HttpMirrorParitySupport.baseMirrorSampler(mirrorPort, HTTPConstants.PUT); + HttpMirrorParitySupport.addFormPair(sampler, TITLE, "mytitle", false); + HttpMirrorParitySupport.addFormPair(sampler, DESCRIPTION, "mydescription", false); + ((org.apache.jmeter.protocol.http.util.HTTPArgument) sampler.getArguments().getArgument(0)) + .setAlwaysEncoded(false); + ((org.apache.jmeter.protocol.http.util.HTTPArgument) sampler.getArguments().getArgument(1)) + .setAlwaysEncoded(false); + + MirrorParityResult result = HttpMirrorParitySupport.runMirrorParity( + client, sampler, "PUT form"); + result.assertRequestLineMatches(); + result.assertPostBodyContains("title=mytitle"); + result.assertPostBodyContains("description=mydescription"); + } + + private static int findFreePort() throws Exception { + try (java.net.ServerSocket socket = new java.net.ServerSocket(0)) { + return socket.getLocalPort(); + } + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/parity/HttpMirrorRawBodyParityTest.java b/src/test/java/com/blazemeter/jmeter/http2/parity/HttpMirrorRawBodyParityTest.java new file mode 100644 index 0000000..ba21e26 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/parity/HttpMirrorRawBodyParityTest.java @@ -0,0 +1,165 @@ +package com.blazemeter.jmeter.http2.parity; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import com.blazemeter.jmeter.http2.core.HTTP2JettyClient; +import com.blazemeter.jmeter.http2.parity.HttpMirrorParitySupport.MirrorParityResult; +import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler; +import com.blazemeter.jmeter.http2.sampler.JMeterTestUtils; +import java.util.Arrays; +import java.util.Collection; +import org.apache.jmeter.protocol.http.control.HttpMirrorServer; +import org.apache.jmeter.protocol.http.util.HTTPArgument; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.junit.After; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +/** Port of Apache {@code testPostRequest_BodyFromParameterValues} scenarios (items 0–9). */ +@RunWith(Parameterized.class) +public class HttpMirrorRawBodyParityTest extends HTTP2TestBase { + + private static final String ISO_8859_1 = "ISO-8859-1"; + + @Parameterized.Parameter + public int item; + + private HttpMirrorServer mirrorServer; + private HTTP2JettyClient client; + private int mirrorPort; + + @Parameterized.Parameters(name = "raw-body-item-{0}") + public static Collection data() { + return Arrays.asList(0, 1, 2, 3, 4, 5, 6, 7, 8, 9); + } + + @BeforeClass + public static void setupClass() { + JMeterTestUtils.setupJmeterEnv(); + } + + @Before + public void setUp() throws Exception { + mirrorPort = findFreePort(); + mirrorServer = new HttpMirrorServer(mirrorPort, 10, 10); + mirrorServer.start(); + client = HttpClient4PluginParitySupport.newHttp1PluginClient("mirror-raw-body"); + } + + @After + public void tearDown() throws Exception { + HttpMirrorParitySupport.clearMirrorVariables(); + if (client != null) { + client.stop(); + } + if (mirrorServer != null) { + mirrorServer.stopServer(); + } + } + + @Test + public void rawBodyEchoMatchesHttpClient4() throws Exception { + HTTP2Sampler sampler = buildRawBodySampler(item); + MirrorParityResult result = HttpMirrorParitySupport.runMirrorParity( + client, sampler, "raw body item " + item); + result.assertRawPostBodyMatches(); + } + + private HTTP2Sampler buildRawBodySampler(int test) throws Exception { + String titleValue = "mytitle"; + String descriptionValue = "mydescription"; + String contentEncoding = ""; + boolean alwaysEncoded = false; + + switch (test) { + case 0: + break; + case 1: + contentEncoding = ISO_8859_1; + break; + case 2: + contentEncoding = "UTF-8"; + titleValue = "mytitleœ₡ĕÅ"; + descriptionValue = "mydescriptionœ₡ĕÅ"; + break; + case 3: + contentEncoding = "UTF-8"; + titleValue = "mytitle/="; + descriptionValue = "mydescription /\\"; + break; + case 4: + contentEncoding = "UTF-8"; + titleValue = "mytitle/="; + descriptionValue = "mydescription /\\"; + alwaysEncoded = true; + break; + case 5: + contentEncoding = "UTF-8"; + titleValue = "mytitle%2F%3D"; + descriptionValue = "mydescription+++%2F%5C"; + break; + case 6: + contentEncoding = "UTF-8"; + titleValue = "mytitle%2F%3D"; + descriptionValue = "mydescription+++%2F%5C"; + alwaysEncoded = true; + break; + case 7: + contentEncoding = "UTF-8"; + titleValue = "/wEPDwULLTE2MzM2OTA0NTYPZBYCAgMPZ/rA+8DZ2dnZ2dnZ2d/GNDar6OshPwdJc="; + descriptionValue = "mydescription"; + break; + case 8: + contentEncoding = "UTF-8"; + titleValue = "mytitle++"; + descriptionValue = "mydescription+"; + break; + case 9: + return buildRawBodyWithVariablesSampler(); + default: + throw new IllegalArgumentException("Unsupported raw body item: " + test); + } + + HTTP2Sampler sampler = baseRawBodySampler(contentEncoding); + addRawValue(sampler, titleValue, alwaysEncoded); + addRawValue(sampler, descriptionValue, alwaysEncoded); + return sampler; + } + + private HTTP2Sampler buildRawBodyWithVariablesSampler() throws Exception { + HttpMirrorParitySupport.setupMirrorVariables(); + HTTP2Sampler sampler = baseRawBodySampler("UTF-8"); + addRawValue(sampler, "${title_prefix}mytitleœ₡ĕÅ", false); + addRawValue(sampler, "mydescriptionœ₡ĕÅ${description_suffix}", false); + HttpMirrorParitySupport.replaceSamplerVariables(sampler); + return sampler; + } + + private static void addRawValue(HTTP2Sampler sampler, String value, boolean alwaysEncoded) { + org.apache.jmeter.config.Arguments args = sampler.getArguments(); + if (args == null) { + args = new org.apache.jmeter.config.Arguments(); + sampler.setArguments(args); + } + HTTPArgument arg = new HTTPArgument("", value, alwaysEncoded); + arg.setAlwaysEncoded(alwaysEncoded); + args.addArgument(arg); + } + + private HTTP2Sampler baseRawBodySampler(String contentEncoding) { + HTTP2Sampler sampler = HttpMirrorParitySupport.baseMirrorSampler(mirrorPort, HTTPConstants.POST); + sampler.setPostBodyRaw(true); + if (!contentEncoding.isEmpty()) { + sampler.setContentEncoding(contentEncoding); + } + return sampler; + } + + private static int findFreePort() throws Exception { + try (java.net.ServerSocket socket = new java.net.ServerSocket(0)) { + return socket.getLocalPort(); + } + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/parity/HttpRedirectsFollowParityTest.java b/src/test/java/com/blazemeter/jmeter/http2/parity/HttpRedirectsFollowParityTest.java new file mode 100644 index 0000000..61d063d --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/parity/HttpRedirectsFollowParityTest.java @@ -0,0 +1,113 @@ +package com.blazemeter.jmeter.http2.parity; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import com.blazemeter.jmeter.http2.core.HTTP2JettyClient; +import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler; +import com.blazemeter.jmeter.http2.sampler.JMeterTestUtils; +import java.net.URL; +import java.util.Arrays; +import java.util.Collection; +import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; +import org.junit.After; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +/** + * Redirect parity with automatic follow enabled ({@code followRedirects} + {@code autoRedirects}). + * Jetty and HttpClient4 both follow at the client layer when {@code autoRedirects} is true. + */ +@RunWith(Parameterized.class) +public class HttpRedirectsFollowParityTest extends HTTP2TestBase { + + @Parameterized.Parameter(0) + public int redirectCode; + + @Parameterized.Parameter(1) + public String method; + + @Parameterized.Parameter(2) + public boolean shouldFollowToTarget; + + private ParityRedirectServer redirectServer; + private HTTP2JettyClient client; + private HTTP2Sampler sampler; + + @Parameterized.Parameters(name = "follow-{0}-{1}-toTarget={2}") + public static Collection data() { + return Arrays.asList( + row(301, "GET", true), + row(301, "HEAD", true), + row(302, "GET", true), + row(302, "HEAD", true), + row(303, "GET", true), + row(303, "HEAD", true), + row(307, "GET", true), + row(307, "HEAD", true), + row(307, "POST", true), + row(308, "GET", true), + row(308, "HEAD", true)); + } + + private static Object[] row(int code, String method, boolean shouldFollow) { + return new Object[] {code, method, shouldFollow}; + } + + @BeforeClass + public static void setupClass() { + JMeterTestUtils.setupJmeterEnv(); + } + + @Before + public void setUp() throws Exception { + redirectServer = new ParityRedirectServer(); + client = HttpClient4PluginParitySupport.newHttp1PluginClient("redirect-follow-parity"); + sampler = new HTTP2Sampler(); + sampler.setFollowRedirects(true); + sampler.setAutoRedirects(true); + sampler.setUseKeepAlive(true); + sampler.setMethod(method); + sampler.setProtocol("http"); + sampler.setDomain("localhost"); + sampler.setPort(redirectServer.getPort()); + sampler.setPath("/some-location?status=" + redirectCode); + } + + @After + public void tearDown() throws Exception { + if (client != null) { + client.stop(); + } + if (redirectServer != null) { + redirectServer.close(); + } + } + + @Test + public void pluginMatchesHttpClient4WhenFollowingRedirects() throws Exception { + URL url = new URL(redirectServer.url("/some-location?status=" + redirectCode)); + HTTPSampleResult reference = HttpClient4PluginParitySupport.sampleHttpClient4(sampler, url); + HTTPSampleResult plugin = HttpClient4PluginParitySupport.samplePlugin(client, sampler, url); + + HttpClient4PluginParitySupport.assertCoreParity(reference, plugin, + "follow " + redirectCode + " " + method); + + if (shouldFollowToTarget) { + assertThat(reference.getResponseCode()).isEqualTo("200"); + assertThat(plugin.getResponseCode()).isEqualTo("200"); + assertThat(reference.getURL().toString()).endsWith("/target"); + assertThat(plugin.getURL().toString()).endsWith("/target"); + if ("GET".equals(method)) { + HttpClient4PluginParitySupport.assertResponseBodyParity(reference, plugin, + "follow GET body"); + } + } else { + assertThat(reference.getResponseCode()).isEqualTo(String.valueOf(redirectCode)); + assertThat(plugin.getResponseCode()).isEqualTo(String.valueOf(redirectCode)); + } + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/parity/HttpRedirectsParityTest.java b/src/test/java/com/blazemeter/jmeter/http2/parity/HttpRedirectsParityTest.java new file mode 100644 index 0000000..c38fc7a --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/parity/HttpRedirectsParityTest.java @@ -0,0 +1,119 @@ +package com.blazemeter.jmeter.http2.parity; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import com.blazemeter.jmeter.http2.core.HTTP2JettyClient; +import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler; +import com.blazemeter.jmeter.http2.sampler.JMeterTestUtils; +import java.net.URL; +import java.util.Arrays; +import java.util.Collection; +import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.junit.After; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +/** + * Port of Apache JMeter {@code TestRedirects}: redirect location and status with follow disabled. + */ +@RunWith(Parameterized.class) +public class HttpRedirectsParityTest extends HTTP2TestBase { + + @Parameterized.Parameter(0) + public int redirectCode; + + @Parameterized.Parameter(1) + public String method; + + @Parameterized.Parameter(2) + public boolean shouldExposeRedirectLocation; + + private ParityRedirectServer redirectServer; + private HTTP2JettyClient client; + private HTTP2Sampler sampler; + + @Parameterized.Parameters(name = "{0}-{1}-redirect={2}") + public static Collection data() { + return Arrays.asList( + row(301, "GET", true), + row(301, "HEAD", true), + row(301, "POST", true), + row(301, "PUT", true), + row(301, "DELETE", true), + row(302, "GET", true), + row(302, "HEAD", true), + row(302, "POST", true), + row(303, "GET", true), + row(303, "POST", true), + row(307, "GET", true), + row(307, "HEAD", true), + row(307, "POST", false), + row(307, "PUT", false), + row(308, "GET", true), + row(308, "HEAD", true), + row(308, "POST", true), + row(300, "GET", false), + row(304, "GET", false), + row(305, "GET", false), + row(306, "GET", false)); + } + + private static Object[] row(int code, String method, boolean shouldRedirect) { + return new Object[] {code, method, shouldRedirect}; + } + + @BeforeClass + public static void setupClass() { + JMeterTestUtils.setupJmeterEnv(); + } + + @Before + public void setUp() throws Exception { + redirectServer = new ParityRedirectServer(); + client = HttpClient4PluginParitySupport.newHttp1PluginClient("redirect-parity"); + sampler = new HTTP2Sampler(); + sampler.setFollowRedirects(false); + sampler.setAutoRedirects(false); + sampler.setUseKeepAlive(true); + sampler.setMethod(method); + sampler.setProtocol("http"); + sampler.setDomain("localhost"); + sampler.setPort(redirectServer.getPort()); + sampler.setPath("/some-location?status=" + redirectCode); + } + + @After + public void tearDown() throws Exception { + if (client != null) { + client.stop(); + } + if (redirectServer != null) { + redirectServer.close(); + } + } + + @Test + public void pluginMatchesHttpClient4RedirectSemantics() throws Exception { + URL url = new URL(redirectServer.url("/some-location?status=" + redirectCode)); + HTTPSampleResult reference = HttpClient4PluginParitySupport.sampleHttpClient4(sampler, url); + HTTPSampleResult plugin = HttpClient4PluginParitySupport.samplePlugin(client, sampler, url); + + HttpClient4PluginParitySupport.assertCoreParity(reference, plugin, + redirectCode + " " + method); + + String expectedLocation = shouldExposeRedirectLocation + ? redirectServer.url("/target") + : null; + org.assertj.core.api.Assertions.assertThat(reference.getRedirectLocation()) + .isEqualTo(expectedLocation); + org.assertj.core.api.Assertions.assertThat(plugin.getRedirectLocation()) + .isEqualTo(expectedLocation); + org.assertj.core.api.Assertions.assertThat(reference.getResponseCode()) + .isEqualTo(String.valueOf(redirectCode)); + org.assertj.core.api.Assertions.assertThat(plugin.getResponseCode()) + .isEqualTo(String.valueOf(redirectCode)); + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/parity/ParityRedirectServer.java b/src/test/java/com/blazemeter/jmeter/http2/parity/ParityRedirectServer.java new file mode 100644 index 0000000..7bcadc9 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/parity/ParityRedirectServer.java @@ -0,0 +1,75 @@ +package com.blazemeter.jmeter.http2.parity; + +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.eclipse.jetty.ee10.servlet.ServletContextHandler; +import org.eclipse.jetty.ee10.servlet.ServletHolder; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; + +/** Minimal HTTP/1 server that returns configurable redirect responses for parity tests. */ +public final class ParityRedirectServer implements AutoCloseable { + + private final Server server; + private final int port; + + public ParityRedirectServer() throws Exception { + server = new Server(); + ServerConnector connector = new ServerConnector(server); + connector.setPort(0); + server.addConnector(connector); + ServletContextHandler context = new ServletContextHandler(); + context.addServlet(new ServletHolder(new RedirectServlet()), "/*"); + server.setHandler(context); + server.start(); + port = connector.getLocalPort(); + } + + public int getPort() { + return port; + } + + public String url(String path) { + String normalized = path.startsWith("/") ? path : "/" + path; + return "http://localhost:" + port + normalized; + } + + @Override + public void close() throws Exception { + server.stop(); + } + + private final class RedirectServlet extends HttpServlet { + + @Override + protected void service(HttpServletRequest req, HttpServletResponse resp) throws IOException { + String path = req.getRequestURI(); + if ("/target".equals(path)) { + resp.setStatus(HttpServletResponse.SC_OK); + resp.getWriter().write("ok"); + return; + } + if ("/some-location".equals(path)) { + int status = parseStatus(req.getParameter("status"), HttpServletResponse.SC_FOUND); + resp.setStatus(status); + resp.setHeader(HTTPConstants.HEADER_LOCATION, url("/target")); + return; + } + resp.setStatus(HttpServletResponse.SC_NOT_FOUND); + } + + private int parseStatus(String raw, int defaultStatus) { + if (raw == null || raw.isBlank()) { + return defaultStatus; + } + try { + return Integer.parseInt(raw.trim()); + } catch (NumberFormatException e) { + return defaultStatus; + } + } + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/regression/HttpsampleResultComparator.java b/src/test/java/com/blazemeter/jmeter/http2/regression/HttpsampleResultComparator.java new file mode 100644 index 0000000..3426944 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/regression/HttpsampleResultComparator.java @@ -0,0 +1,218 @@ +package com.blazemeter.jmeter.http2.regression; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** Semantic diff of two JMeter sample trees (ignores timings and volatile headers). */ +public final class HttpsampleResultComparator { + + private static final Set IGNORED_RESPONSE_HEADER_NAMES = new HashSet<>(Arrays.asList( + "date", "server", "connection", "transfer-encoding", "keep-alive", + "content-length", "alt-svc", "via", "x-firefox-spdy", "x-powered-by", + "cf-ray", "age", "cf-cache-status", "accept-ranges", "set-cookie")); + + /** Headers compared between ref/plugin runs; others vary by timing or CDN edge. */ + private static final Set COMPARED_RESPONSE_HEADER_NAMES = new HashSet<>(Arrays.asList( + "content-encoding", "content-type", "location")); + + private static final Pattern EMBEDDED_FILE_LABEL_INDEX = + Pattern.compile("^(?:file:.+|.+)-(\\d+)$"); + + private HttpsampleResultComparator() { + } + + public static ComparisonResult compare(List reference, + List actual) { + return compare(reference, actual, false); + } + + public static ComparisonResult compare(List reference, + List actual, boolean tolerateExternalServiceDrift) { + List differences = new ArrayList<>(); + compareLists(reference, actual, "", differences, tolerateExternalServiceDrift); + return new ComparisonResult(differences.isEmpty(), differences); + } + + private static void compareLists(List reference, List actual, + String path, List differences, boolean tolerateExternalServiceDrift) { + if (reference.size() != actual.size()) { + differences.add(path + "sample count: expected " + reference.size() + + " but was " + actual.size()); + int limit = Math.min(reference.size(), actual.size()); + for (int i = 0; i < limit; i++) { + compareSample(reference.get(i), actual.get(i), path + "[" + i + "].", differences, + tolerateExternalServiceDrift); + } + return; + } + for (int i = 0; i < reference.size(); i++) { + compareSample(reference.get(i), actual.get(i), path + "[" + i + "].", differences, + tolerateExternalServiceDrift); + } + } + + private static void compareSample(SampleRecord expected, SampleRecord actual, String path, + List differences, boolean tolerateExternalServiceDrift) { + if (!labelsEquivalent(expected.getLabel(), actual.getLabel())) { + differences.add(path + "label: expected '" + expected.getLabel() + + "' but was '" + actual.getLabel() + "'"); + } + if (tolerateExternalServiceDrift && isExternalServiceDrift(expected, actual)) { + return; + } + if (expected.isSuccessful() != actual.isSuccessful()) { + differences.add(path + "success: expected " + expected.isSuccessful() + + " but was " + actual.isSuccessful() + + " (code=" + actual.getResponseCode() + " msg=" + actual.getResponseMessage() + ")"); + } + if (!normalizeResponseCode(expected.getResponseCode()) + .equals(normalizeResponseCode(actual.getResponseCode()))) { + differences.add(path + "responseCode: expected " + expected.getResponseCode() + + " but was " + actual.getResponseCode()); + } + if (!normalizeMessage(expected.getResponseMessage()) + .equals(normalizeMessage(actual.getResponseMessage()))) { + differences.add(path + "responseMessage: expected '" + expected.getResponseMessage() + + "' but was '" + actual.getResponseMessage() + "'"); + } + if (!normalizeBody(expected.getResponseData()).equals(normalizeBody(actual.getResponseData()))) { + differences.add(path + "responseData differs for label '" + expected.getLabel() + "'"); + } + Map expectedHeaders = normalizeHeaders(expected.getResponseHeaders()); + Map actualHeaders = normalizeHeaders(actual.getResponseHeaders()); + for (Map.Entry entry : expectedHeaders.entrySet()) { + String key = entry.getKey(); + if (!actualHeaders.containsKey(key)) { + differences.add(path + "missing response header '" + key + "'"); + } else if (!entry.getValue().equals(actualHeaders.get(key))) { + differences.add(path + "response header '" + key + "': expected '" + entry.getValue() + + "' but was '" + actualHeaders.get(key) + "'"); + } + } + compareLists(expected.getChildren(), actual.getChildren(), path + "children.", + differences, tolerateExternalServiceDrift); + } + + /** One run saw a transient upstream 5xx while the other succeeded (or vice versa). */ + private static boolean isExternalServiceDrift(SampleRecord expected, SampleRecord actual) { + int expectedCode = parseHttpStatus(expected.getResponseCode()); + int actualCode = parseHttpStatus(actual.getResponseCode()); + if (expectedCode < 0 || actualCode < 0) { + return false; + } + boolean expectedTransient = expectedCode >= 502 && expectedCode <= 504; + boolean actualTransient = actualCode >= 502 && actualCode <= 504; + boolean expectedOk = expectedCode >= 200 && expectedCode < 300; + boolean actualOk = actualCode >= 200 && actualCode < 300; + boolean expectedRedirect = expectedCode >= 301 && expectedCode < 400; + boolean actualRedirect = actualCode >= 301 && actualCode < 400; + return (expectedTransient && (actualOk || actualRedirect)) + || (actualTransient && (expectedOk || expectedRedirect)); + } + + private static int parseHttpStatus(String code) { + if (code == null || code.isBlank()) { + return -1; + } + try { + return Integer.parseInt(code.trim()); + } catch (NumberFormatException e) { + return -1; + } + } + + private static String normalizeResponseCode(String code) { + if (code == null) { + return ""; + } + if (code.contains("ConnectException")) { + return "connect-failure"; + } + return code; + } + + private static String normalizeMessage(String message) { + if (message == null) { + return ""; + } + String normalized = message.trim(); + if (normalized.contains("Connection refused")) { + return "connection-refused"; + } + return normalized; + } + + private static String normalizeBody(String body) { + if (body == null) { + return ""; + } + return body.replace("\r\n", "\n").trim(); + } + + private static Map normalizeHeaders(String raw) { + Map headers = new HashMap<>(); + if (raw == null || raw.trim().isEmpty()) { + return headers; + } + String[] lines = raw.replace("\r\n", "\n").split("\n"); + for (String line : lines) { + int colon = line.indexOf(':'); + if (colon <= 0) { + continue; + } + String name = line.substring(0, colon).trim().toLowerCase(Locale.ROOT); + if (IGNORED_RESPONSE_HEADER_NAMES.contains(name) + || !COMPARED_RESPONSE_HEADER_NAMES.contains(name)) { + continue; + } + String value = line.substring(colon + 1).trim(); + headers.put(name, value); + } + return headers; + } + + /** + * HttpClient4 labels file embedded resources as {@code file:-N}; the plugin may use the + * parent sampler name. The numeric suffix is the stable identity for parity checks. + */ + private static boolean labelsEquivalent(String expected, String actual) { + if (expected.equals(actual)) { + return true; + } + Matcher expectedIndex = EMBEDDED_FILE_LABEL_INDEX.matcher(expected); + Matcher actualIndex = EMBEDDED_FILE_LABEL_INDEX.matcher(actual); + return expectedIndex.matches() && actualIndex.matches() + && expectedIndex.group(1).equals(actualIndex.group(1)); + } + + public static final class ComparisonResult { + private final boolean equal; + private final List differences; + + ComparisonResult(boolean equal, List differences) { + this.equal = equal; + this.differences = differences; + } + + public boolean isEqual() { + return equal; + } + + public List getDifferences() { + return differences; + } + + public String formattedDiff() { + return differences.stream().collect(Collectors.joining(System.lineSeparator())); + } + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/regression/JmeterDistribution.java b/src/test/java/com/blazemeter/jmeter/http2/regression/JmeterDistribution.java new file mode 100644 index 0000000..b446c2c --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/regression/JmeterDistribution.java @@ -0,0 +1,183 @@ +package com.blazemeter.jmeter.http2.regression; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.Comparator; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import org.apache.commons.io.FileUtils; + +/** + * Locates or downloads an Apache JMeter binary distribution for forked regression runs. + */ +public final class JmeterDistribution { + + private static final String VERSION = System.getProperty( + "jmeter.regression.version", "5.6.3"); + private static final String DOWNLOAD_URL = "https://archive.apache.org/dist/jmeter/binaries/" + + "apache-jmeter-" + VERSION + ".zip"; + + private final Path homeDir; + private final Path binDir; + private final File jmeterJar; + + private JmeterDistribution(Path homeDir) { + this.homeDir = homeDir; + this.binDir = homeDir.resolve("bin"); + this.jmeterJar = binDir.resolve("ApacheJMeter.jar").toFile(); + } + + public static JmeterDistribution resolve() throws IOException { + String explicit = System.getProperty("jmeter.home"); + if (explicit != null && !explicit.trim().isEmpty()) { + Path home = Paths.get(explicit.trim()); + JmeterDistribution dist = new JmeterDistribution(home); + if (!dist.jmeterJar.isFile()) { + throw new IOException("jmeter.home does not contain bin/ApacheJMeter.jar: " + home); + } + return dist; + } + + Path baseDir = Paths.get("target", "jmeter-dist-" + VERSION).toAbsolutePath(); + Path home = provisionHome(baseDir); + JmeterDistribution dist = new JmeterDistribution(home); + if (!dist.jmeterJar.isFile()) { + throw new IOException("Failed to provision JMeter " + VERSION + " under " + baseDir); + } + return dist; + } + + public Path getHomeDir() { + return homeDir; + } + + public Path getBinDir() { + return binDir; + } + + public File getJmeterJar() { + return jmeterJar; + } + + public void installPluginJar(File pluginJar) throws IOException { + Path extDir = homeDir.resolve("lib").resolve("ext"); + Files.createDirectories(extDir); + Files.copy(pluginJar.toPath(), extDir.resolve(pluginJar.getName()), + StandardCopyOption.REPLACE_EXISTING); + } + + public void removePluginJars() throws IOException { + Path extDir = homeDir.resolve("lib").resolve("ext"); + if (!Files.isDirectory(extDir)) { + return; + } + try (Stream stream = Files.list(extDir)) { + stream.filter(path -> path.getFileName().toString().startsWith("jmeter-bzm-http")) + .forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + throw new IllegalStateException("Could not delete " + path, e); + } + }); + } + } + + private static Path provisionHome(Path baseDir) throws IOException { + Path existing = findJmeterHomeIfPresent(baseDir); + if (existing != null) { + return existing; + } + Files.createDirectories(baseDir); + Path zipPath = baseDir.resolve("apache-jmeter-" + VERSION + ".zip"); + if (!Files.isRegularFile(zipPath)) { + download(zipPath); + } + Path extractRoot = baseDir.resolve("extract"); + if (Files.exists(extractRoot)) { + FileUtils.deleteDirectory(extractRoot.toFile()); + } + unzip(zipPath, extractRoot); + Path extractedHome = findJmeterHome(extractRoot); + return extractedHome; + } + + private static Path findJmeterHomeIfPresent(Path baseDir) throws IOException { + if (!Files.isDirectory(baseDir)) { + return null; + } + Path fromExtract = baseDir.resolve("extract"); + if (Files.isDirectory(fromExtract)) { + try { + return findJmeterHome(fromExtract); + } catch (IOException ignored) { + return null; + } + } + try { + return findJmeterHome(baseDir); + } catch (IOException ignored) { + return null; + } + } + + private static void download(Path destination) throws IOException { + URL url = URI.create(DOWNLOAD_URL).toURL(); + try (InputStream in = url.openStream()) { + Files.copy(in, destination, StandardCopyOption.REPLACE_EXISTING); + } + } + + private static void unzip(Path zipFile, Path destination) throws IOException { + Files.createDirectories(destination); + try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(zipFile))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + Path outPath = destination.resolve(entry.getName()).normalize(); + if (!outPath.startsWith(destination)) { + throw new IOException("Zip entry outside target dir: " + entry.getName()); + } + if (entry.isDirectory()) { + Files.createDirectories(outPath); + } else { + Files.createDirectories(outPath.getParent()); + Files.copy(zis, outPath, StandardCopyOption.REPLACE_EXISTING); + } + zis.closeEntry(); + } + } + } + + private static Path findJmeterHome(Path extractRoot) throws IOException { + try (Stream stream = Files.walk(extractRoot, 3)) { + return stream + .filter(path -> path.getFileName().toString().equals("ApacheJMeter.jar")) + .map(path -> path.getParent().getParent()) + .findFirst() + .orElseThrow(() -> new IOException("ApacheJMeter.jar not found under " + extractRoot)); + } + } + + static void deleteRecursively(Path path) throws IOException { + if (!Files.exists(path)) { + return; + } + try (Stream walk = Files.walk(path)) { + walk.sorted(Comparator.reverseOrder()).forEach(p -> { + try { + Files.deleteIfExists(p); + } catch (IOException e) { + throw new IllegalStateException(e); + } + }); + } + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/regression/JmeterHttpRegressionIntegrationTest.java b/src/test/java/com/blazemeter/jmeter/http2/regression/JmeterHttpRegressionIntegrationTest.java new file mode 100644 index 0000000..51e61c1 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/regression/JmeterHttpRegressionIntegrationTest.java @@ -0,0 +1,194 @@ +package com.blazemeter.jmeter.http2.regression; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeTrue; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import com.blazemeter.jmeter.http2.sampler.JmxBlazeMeterHttpMigrator; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.apache.jorphan.collections.HashTree; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +/** + * Runs Apache JMeter {@code bin/testfiles} plans against HttpClient4 (reference) and migrated + * {@code HTTP2Sampler} (plugin), comparing sample results semantically. + */ +@RunWith(Parameterized.class) +public class JmeterHttpRegressionIntegrationTest extends HTTP2TestBase { + + private static final String REF_IMPL = "HttpClient4"; + private static final String PLUGIN_IMPL = "BzmHttp"; + + private static JmeterDistribution distribution; + private static JmeterRegressionRunner runner; + private static Path workRoot; + private static File pluginJar; + private static boolean http3Enabled; + + private final String testName; + private final RegressionProtocolProfile protocolProfile; + + public JmeterHttpRegressionIntegrationTest(String testName, + RegressionProtocolProfile protocolProfile) { + this.testName = testName; + this.protocolProfile = protocolProfile; + } + + @Parameterized.Parameters(name = "{0}-{1}") + public static List data() { + List rows = new ArrayList<>(); + RegressionProtocolProfile protocolFilter = resolveProtocolFilter(); + for (String test : JmeterRegressionSupport.configuredTestNames()) { + String baseName = JmeterRegressionSupport.testBaseName(test); + for (RegressionProtocolProfile profile : RegressionProtocolProfile.values()) { + if (profile == RegressionProtocolProfile.HTTP3 && !Boolean.getBoolean("it.http3")) { + continue; + } + if (profile != RegressionProtocolProfile.HTTP1_ONLY + && JmeterRegressionSupport.isHttp1OnlyPlan(baseName)) { + continue; + } + if (protocolFilter != null && profile != protocolFilter) { + continue; + } + rows.add(new Object[] {test, profile}); + } + } + return rows; + } + + private static RegressionProtocolProfile resolveProtocolFilter() { + String value = System.getProperty("jmeter.regression.protocol"); + if (value == null || value.isBlank()) { + return null; + } + return RegressionProtocolProfile.fromSystemProperty(); + } + + @BeforeClass + public static void setUpClass() throws Exception { + assumeTrue("Set -Djmeter.regression=true to run JMeter regression tests", + Boolean.getBoolean("jmeter.regression")); + http3Enabled = Boolean.getBoolean("it.http3"); + distribution = JmeterDistribution.resolve(); + workRoot = Path.of("target", "jmeter-regression-work"); + Files.createDirectories(workRoot); + runner = new JmeterRegressionRunner(distribution, workRoot); + pluginJar = resolvePluginJar(); + } + + @AfterClass + public static void tearDownClass() throws Exception { + if (distribution != null) { + distribution.removePluginJars(); + } + } + + @Test + public void referenceAndPluginProduceEquivalentSamples() throws Exception { + if (protocolProfile == RegressionProtocolProfile.HTTP3 && !http3Enabled) { + return; + } + + String baseName = JmeterRegressionSupport.testBaseName(testName); + assumeTrue("Plan requires -Djmeter.regression.enableSlowChars=true (CPS throttling not in Jetty yet)", + !JmeterRegressionSupport.isOptionalUnsupportedPlan(baseName)); + Path caseDir = workRoot.resolve(baseName + "-" + protocolProfile.getId()); + Files.createDirectories(caseDir); + + File sourceJmx = JmeterRegressionSupport.copyResourceToWorkDir(testName + ".jmx", caseDir); + File batchProps = JmeterRegressionSupport.copyResourceToWorkDir( + "jmeter-batch.properties", caseDir); + File log4jXml = JmeterRegressionSupport.copyResourceToWorkDir("log4j2-batch.xml", caseDir); + + String migratedBaseName = baseName + "_bzm"; + JmeterRegressionSupport.cleanupResultArtifacts(distribution, baseName, REF_IMPL, PLUGIN_IMPL); + JmeterRegressionSupport.cleanupPlanCollectorArtifacts(distribution, baseName, migratedBaseName); + JmeterRegressionSupport.stageFixtures(baseName, distribution, caseDir); + + Map refArgs = JmeterRegressionRunner.referenceHttpClient4Args(); + distribution.removePluginJars(); + JmeterRegressionRunner.RunResult refRun = runner.run( + baseName + "-ref-" + protocolProfile.getId(), + sourceJmx, + batchProps, + log4jXml, + refArgs); + + assertEquals("Reference JMeter run failed, see " + refRun.getLogFile(), + 0, refRun.getExitCode()); + assertTrue("Reference log has errors: " + refRun.getLogFile(), + !JmeterRegressionRunner.logHasErrors(refRun.getLogFile())); + + File refSamples = refRun.resolveSampleFile(); + assertNotNull("Reference sample file not found for " + baseName, refSamples); + assertTrue("Reference sample file missing: " + refSamples, refSamples.isFile()); + File archivedRef = JmeterRegressionSupport.archiveResultXml( + refSamples, caseDir, "reference-samples.xml"); + List referenceSamples = JtlSampleLoader.load(archivedRef); + + JmeterRegressionSupport.cleanupPlanCollectorArtifacts(distribution, baseName, migratedBaseName); + + File migratedJmx = caseDir.resolve(migratedBaseName + ".jmx").toFile(); + HashTree tree = JmxBlazeMeterHttpMigrator.loadTree(sourceJmx); + int replaced = JmxBlazeMeterHttpMigrator.migrateTree(tree); + assertTrue("Expected HTTP samplers to migrate in " + testName, replaced > 0); + JmxBlazeMeterHttpMigrator.saveTree(tree, migratedJmx); + + Map pluginArgs = JmeterRegressionRunner.pluginArgs(protocolProfile); + pluginArgs.put("jmeter.httpsampler", PLUGIN_IMPL); + + distribution.installPluginJar(pluginJar); + JmeterRegressionRunner.RunResult pluginRun = runner.run( + baseName + "-plugin-" + protocolProfile.getId(), + migratedJmx, + batchProps, + log4jXml, + pluginArgs); + + assertEquals("Plugin JMeter run failed, see " + pluginRun.getLogFile(), + 0, pluginRun.getExitCode()); + assertTrue("Plugin log has errors: " + pluginRun.getLogFile(), + !JmeterRegressionRunner.logHasErrors(pluginRun.getLogFile())); + + File pluginSamplesFile = pluginRun.resolveSampleFile(); + assertNotNull("Plugin sample file not found for " + baseName, pluginSamplesFile); + assertTrue("Plugin sample file missing: " + pluginSamplesFile, pluginSamplesFile.isFile()); + List pluginSamples = JtlSampleLoader.load(pluginSamplesFile); + + HttpsampleResultComparator.ComparisonResult comparison = + HttpsampleResultComparator.compare(referenceSamples, pluginSamples, + JmeterRegressionSupport.toleratesExternalServiceDrift(baseName)); + assertTrue("Sample mismatch for " + testName + " [" + protocolProfile.getId() + "]: " + + comparison.formattedDiff(), comparison.isEqual()); + } + + private static File resolvePluginJar() throws IOException { + Path targetJar = Path.of("target", "jmeter-bzm-http2-3.0.2-SNAPSHOT.jar"); + if (Files.isRegularFile(targetJar)) { + return targetJar.toFile(); + } + try (var stream = Files.list(Path.of("target"))) { + return stream + .filter(p -> p.getFileName().toString().startsWith("jmeter-bzm-http2") + && p.getFileName().toString().endsWith(".jar") + && !p.getFileName().toString().contains("original")) + .map(Path::toFile) + .findFirst() + .orElseThrow(() -> new IOException( + "Plugin jar not found under target/. Run mvn package first.")); + } + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/regression/JmeterRegressionRunner.java b/src/test/java/com/blazemeter/jmeter/http2/regression/JmeterRegressionRunner.java new file mode 100644 index 0000000..71e1e87 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/regression/JmeterRegressionRunner.java @@ -0,0 +1,193 @@ +package com.blazemeter.jmeter.http2.regression; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** Forks JMeter in non-GUI mode using the same flags as Apache's BatchTest task. */ +public final class JmeterRegressionRunner { + + private static final long DEFAULT_TIMEOUT_MINUTES = Long.parseLong( + System.getProperty("jmeter.regression.timeoutMinutes", "10")); + + private final JmeterDistribution distribution; + private final Path workDir; + + public JmeterRegressionRunner(JmeterDistribution distribution, Path workDir) throws IOException { + this.distribution = distribution; + this.workDir = workDir; + Files.createDirectories(workDir); + } + + public RunResult run(String runId, File jmxFile, File batchProperties, File log4jXml, + Map jmeterArgs) throws IOException, InterruptedException { + Path runDir = workDir.resolve(runId); + Files.createDirectories(runDir); + + File logFile = runDir.resolve("jmeter.log").toFile(); + File jtlFile = runDir.resolve("results.jtl").toFile(); + File errFile = runDir.resolve("jmeter.err").toFile(); + Files.deleteIfExists(jtlFile.toPath()); + Files.deleteIfExists(logFile.toPath()); + Files.deleteIfExists(errFile.toPath()); + + List command = new ArrayList<>(); + command.add(resolveJavaExecutable()); + command.add("-Xms128m"); + command.add("-Xmx512m"); + command.add("-Djava.awt.headless=true"); + command.add("-Duser.language=en"); + command.add("-Duser.region=en"); + command.add("-Duser.country=US"); + command.add("-cp"); + command.add(distribution.getJmeterJar().getAbsolutePath()); + command.add("org.apache.jmeter.NewDriver"); + command.add("-p"); + command.add(distribution.getBinDir().resolve("jmeter.properties").toString()); + command.add("-q"); + command.add(batchProperties.getAbsolutePath()); + command.add("-n"); + command.add("-t"); + command.add(jmxFile.getAbsolutePath()); + command.add("-i"); + command.add(log4jXml.getAbsolutePath()); + command.add("-j"); + command.add(logFile.getAbsolutePath()); + command.add("-l"); + command.add(jtlFile.getAbsolutePath()); + command.add("-Jmodule=Module"); + command.add("-Gmodule=Module"); + + if (jmeterArgs != null) { + for (Map.Entry entry : jmeterArgs.entrySet()) { + command.add("-J" + entry.getKey() + "=" + entry.getValue()); + } + } + + ProcessBuilder builder = new ProcessBuilder(command); + builder.directory(distribution.getBinDir().toFile()); + builder.redirectError(errFile); + Process process = builder.start(); + boolean finished = process.waitFor(DEFAULT_TIMEOUT_MINUTES, TimeUnit.MINUTES); + if (!finished) { + process.destroyForcibly(); + throw new IOException("JMeter timed out after " + DEFAULT_TIMEOUT_MINUTES + " minutes"); + } + + int exitCode = process.exitValue(); + File xmlResult = findXmlResult(runDir, jmxFile); + return new RunResult(runId, exitCode, logFile, jtlFile, xmlResult, errFile); + } + + private File findXmlResult(Path runDir, File jmxFile) { + String baseName = jmxFile.getName(); + if (baseName.endsWith(".jmx")) { + baseName = baseName.substring(0, baseName.length() - 4); + } + File inRunDir = runDir.resolve(baseName + ".xml").toFile(); + if (inRunDir.isFile()) { + return inRunDir; + } + File inBin = distribution.getBinDir().resolve(baseName + ".xml").toFile(); + if (inBin.isFile()) { + return inBin; + } + File jtl = runDir.resolve("results.jtl").toFile(); + return jtl.isFile() ? jtl : null; + } + + private static String resolveJavaExecutable() { + String javaHome = System.getProperty("java.home"); + Path javaBin = Path.of(javaHome, "bin", isWindows() ? "java.exe" : "java"); + return javaBin.toString(); + } + + private static boolean isWindows() { + return System.getProperty("os.name", "").toLowerCase().contains("win"); + } + + public static Map referenceHttpClient4Args() { + Map args = new LinkedHashMap<>(); + args.put("jmeter.httpsampler", "HttpClient4"); + return args; + } + + public static Map pluginArgs(RegressionProtocolProfile profile) { + Map args = new LinkedHashMap<>(); + args.putAll(profile.jmeterProperties()); + return args; + } + + public static boolean logHasErrors(File logFile) throws IOException { + if (logFile == null || !logFile.isFile() || logFile.length() == 0L) { + return false; + } + String content = new String(Files.readAllBytes(logFile.toPath()), StandardCharsets.UTF_8); + return content.contains("ERROR o.a.j.JMeter:") + || content.contains("AssertionFailedError"); + } + + public static final class RunResult { + private final String runId; + private final int exitCode; + private final File logFile; + private final File jtlFile; + private final File xmlResultFile; + private final File errFile; + + RunResult(String runId, int exitCode, File logFile, File jtlFile, File xmlResultFile, + File errFile) { + this.runId = runId; + this.exitCode = exitCode; + this.logFile = logFile; + this.jtlFile = jtlFile; + this.xmlResultFile = xmlResultFile; + this.errFile = errFile; + } + + public String getRunId() { + return runId; + } + + public int getExitCode() { + return exitCode; + } + + public File getLogFile() { + return logFile; + } + + public File getJtlFile() { + return jtlFile; + } + + public File getXmlResultFile() { + return xmlResultFile; + } + + public File getErrFile() { + return errFile; + } + + /** + * Per-run {@code -l results.jtl} is preferred over plan {@code ResultCollector} XML in + * {@code bin/}, which is shared and append-only across runs. + */ + public File resolveSampleFile() { + if (jtlFile != null && jtlFile.isFile() && jtlFile.length() > 0L) { + return jtlFile; + } + if (xmlResultFile != null && xmlResultFile.isFile()) { + return xmlResultFile; + } + return jtlFile; + } + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/regression/JmeterRegressionSupport.java b/src/test/java/com/blazemeter/jmeter/http2/regression/JmeterRegressionSupport.java new file mode 100644 index 0000000..befe5b2 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/regression/JmeterRegressionSupport.java @@ -0,0 +1,242 @@ +package com.blazemeter.jmeter.http2.regression; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; + +/** Shared helpers for JMeter HTTP regression suites. */ +public final class JmeterRegressionSupport { + + public static final String RESOURCE_ROOT = "jmeter-regression/5.6.3"; + + private JmeterRegressionSupport() { + } + + public static final String TIER1_TESTS = + "TEST_HTTP,ResponseDecompression,TestHeaderManager,TestCookieManager"; + + /** F4: HTTPS, HTML embedded parser, digest/basic auth (see docs/jmeter-regression.md). */ + public static final String TIER4_TESTS = + "HTMLParserTestFile_2,TEST_HTTPS,Http4ImplDigestAuth,Http4ImplPreemptiveBasicAuth"; + + /** Optional / flaky Apache batch plans (external services; not in default CI). */ + /** HTTP-focused flaky plans (BUG_62847/Bug54685 are JMeter core-only, not HTTP sampler tests). */ + public static final String TIER_FLAKY_TESTS = "TestKeepAlive,TestRedirectionPolicies"; + + /** Optional; requires HttpClient4 CPS throttling not yet implemented in the Jetty client. */ + public static final String TIER_FLAKY_OPTIONAL_TESTS = "SlowCharsFeature"; + + /** + * Plans that call third-party hosts; ref/plugin runs are sequential so 5xx vs 2xx drift is + * environmental, not a sampler parity gap. + */ + private static final Set EXTERNAL_SERVICE_DRIFT_TESTS = Set.of( + "Http4ImplDigestAuth", + "Http4ImplPreemptiveBasicAuth", + "TestKeepAlive", + "TestRedirectionPolicies", + "SlowCharsFeature"); + + public static boolean isOptionalUnsupportedPlan(String testBaseName) { + return "SlowCharsFeature".equals(testBaseName) + && !Boolean.getBoolean("jmeter.regression.enableSlowChars"); + } + + private static final String DEFAULT_REGRESSION_TESTS = TIER1_TESTS; + + /** Plans that only make sense against HttpClient4 / HTTP/1.1 (keep-alive, Connection: close). */ + private static final Set HTTP1_ONLY_PLANS = Set.of("TestKeepAlive"); + + public static boolean isHttp1OnlyPlan(String testBaseName) { + return HTTP1_ONLY_PLANS.contains(testBaseName); + } + + public static boolean toleratesExternalServiceDrift(String testBaseName) { + if (Boolean.getBoolean("jmeter.regression.tolerateExternalServiceDrift")) { + return true; + } + return EXTERNAL_SERVICE_DRIFT_TESTS.contains(testBaseName); + } + + public static List configuredTestNames() { + String raw = System.getProperty("jmeter.regression.tests"); + if (raw == null || raw.isBlank()) { + raw = resolveTestsForTier(System.getProperty("jmeter.regression.tier")); + } + if (raw == null || raw.isBlank()) { + raw = DEFAULT_REGRESSION_TESTS; + } + return Arrays.stream(raw.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + } + + public static Path regressionResourceRoot() { + return Path.of("src", "test", "resources", RESOURCE_ROOT); + } + + public static File copyResourceToWorkDir(String resourceName, Path workDir) throws IOException { + String resourcePath = RESOURCE_ROOT + "/" + resourceName; + try (InputStream in = JmeterRegressionSupport.class.getClassLoader() + .getResourceAsStream(resourcePath)) { + if (in == null) { + Path fallback = regressionResourceRoot().resolve(resourceName); + if (Files.isRegularFile(fallback)) { + Files.createDirectories(workDir); + Path target = workDir.resolve(resourceName); + Files.copy(fallback, target, StandardCopyOption.REPLACE_EXISTING); + return target.toFile(); + } + throw new IOException("Regression resource not found: " + resourcePath); + } + Files.createDirectories(workDir); + Path target = workDir.resolve(resourceName); + Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING); + return target.toFile(); + } + } + + public static File resolveResultXml(JmeterDistribution distribution, Path workDir, + String testBaseName, String implementationSuffix) { + String suffixed = testBaseName + "_" + implementationSuffix + ".xml"; + File inBin = distribution.getBinDir().resolve(suffixed).toFile(); + if (inBin.isFile()) { + return inBin; + } + File inWork = workDir.resolve(suffixed).toFile(); + if (inWork.isFile()) { + return inWork; + } + String fixed = testBaseName + ".xml"; + File fixedBin = distribution.getBinDir().resolve(fixed).toFile(); + if (fixedBin.isFile()) { + return fixedBin; + } + File fixedWork = workDir.resolve(fixed).toFile(); + if (fixedWork.isFile()) { + return fixedWork; + } + return null; + } + + public static File archiveResultXml(File source, Path workDir, String archiveName) + throws IOException { + if (source == null || !source.isFile()) { + return null; + } + Files.createDirectories(workDir); + Path target = workDir.resolve(archiveName); + Files.copy(source.toPath(), target, StandardCopyOption.REPLACE_EXISTING); + return target.toFile(); + } + + public static void cleanupResultArtifacts(JmeterDistribution distribution, String testBaseName, + String... suffixes) throws IOException { + cleanupPlanCollectorArtifacts(distribution, testBaseName); + for (String suffix : suffixes) { + deleteIfExists(distribution.getBinDir().resolve(testBaseName + "_" + suffix + ".xml")); + deleteIfExists(distribution.getBinDir().resolve(testBaseName + "_" + suffix + ".csv")); + deleteIfExists(distribution.getBinDir().resolve(testBaseName + ".jtl")); + deleteIfExists(distribution.getBinDir().resolve(testBaseName + ".log")); + } + } + + /** + * Deletes {@code ResultCollector} outputs under JMeter {@code bin/}. Those files are opened in + * append mode and must be removed before each forked run to avoid duplicated samples. + */ + public static void cleanupPlanCollectorArtifacts(JmeterDistribution distribution, + String... planBaseNames) throws IOException { + Path bin = distribution.getBinDir(); + for (String baseName : planBaseNames) { + if (baseName == null || baseName.isEmpty()) { + continue; + } + deleteIfExists(bin.resolve(baseName + ".xml")); + deleteIfExists(bin.resolve(baseName + ".csv")); + } + } + + private static void deleteIfExists(Path path) throws IOException { + Files.deleteIfExists(path); + } + + public static String testBaseName(String testName) { + if (testName.endsWith(".jmx")) { + return testName.substring(0, testName.length() - 4); + } + return testName; + } + + private static String resolveTestsForTier(String tier) { + if (tier == null || tier.isBlank()) { + return null; + } + return switch (tier.trim().toLowerCase(Locale.ROOT)) { + case "1", "tier1", "tier-1" -> TIER1_TESTS; + case "4", "f4", "tier4", "tier-4" -> TIER4_TESTS; + case "flaky", "optional" -> TIER_FLAKY_TESTS; + case "all" -> TIER1_TESTS + "," + TIER4_TESTS + "," + TIER_FLAKY_TESTS; + default -> null; + }; + } + + /** Copies plan-specific fixtures into JMeter {@code bin/} (and optionally the case work dir). */ + public static void stageFixtures(String baseName, JmeterDistribution distribution, Path caseDir) + throws IOException { + if ("TEST_HTTP".equals(baseName)) { + stageTestHttpFixtures(distribution, caseDir); + } else if ("HTMLParserTestFile_2".equals(baseName)) { + stageHtmlParserFixtures(distribution, caseDir); + } + } + + /** Copies fixture files required by {@code TEST_HTTP.jmx} into JMeter {@code bin/} and {@code caseDir}. */ + public static void stageTestHttpFixtures(JmeterDistribution distribution, Path caseDir) + throws IOException { + for (String fixture : new String[] {"user.properties", "TEST_GET.jmx"}) { + copyResourceToWorkDir(fixture, distribution.getBinDir()); + if (caseDir != null) { + copyResourceToWorkDir(fixture, caseDir); + } + } + } + + /** Copies {@code testfiles/} tree required by {@code HTMLParserTestFile_2.jmx}. */ + public static void stageHtmlParserFixtures(JmeterDistribution distribution, Path caseDir) + throws IOException { + Path sourceRoot = regressionResourceRoot().resolve("testfiles"); + if (!Files.isDirectory(sourceRoot)) { + throw new IOException("HTML parser fixtures missing: " + sourceRoot); + } + copyResourceTree(sourceRoot, distribution.getBinDir().resolve("testfiles")); + if (caseDir != null) { + copyResourceTree(sourceRoot, caseDir.resolve("testfiles")); + } + } + + private static void copyResourceTree(Path sourceRoot, Path targetRoot) throws IOException { + Files.createDirectories(targetRoot); + try (var stream = Files.walk(sourceRoot)) { + for (Path source : stream.toList()) { + Path relative = sourceRoot.relativize(source); + Path target = targetRoot.resolve(relative); + if (Files.isDirectory(source)) { + Files.createDirectories(target); + } else { + Files.createDirectories(target.getParent()); + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); + } + } + } + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/regression/JtlSampleLoader.java b/src/test/java/com/blazemeter/jmeter/http2/regression/JtlSampleLoader.java new file mode 100644 index 0000000..49a835d --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/regression/JtlSampleLoader.java @@ -0,0 +1,99 @@ +package com.blazemeter.jmeter.http2.regression; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +/** Loads JMeter XML sample logs (JTL or Result Collector output). */ +public final class JtlSampleLoader { + + private JtlSampleLoader() { + } + + public static List load(File xmlFile) throws Exception { + if (!xmlFile.isFile()) { + throw new IOException("Sample file not found: " + xmlFile); + } + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setExpandEntityReferences(false); + DocumentBuilder builder = factory.newDocumentBuilder(); + Document doc = builder.parse(xmlFile); + Element root = doc.getDocumentElement(); + List samples = new ArrayList<>(); + if ("testResults".equals(root.getTagName())) { + collectTopLevelSamples(root, samples); + } else if (isSampleElement(root)) { + samples.add(parseSample(root)); + } else { + collectTopLevelSamples(root, samples); + } + return samples; + } + + private static void collectTopLevelSamples(Element parent, List out) { + NodeList children = parent.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + Node node = children.item(i); + if (node instanceof Element && isSampleElement((Element) node)) { + out.add(parseSample((Element) node)); + } + } + } + + private static boolean isSampleElement(Element element) { + String tag = element.getTagName(); + return "httpSample".equals(tag) + || "sample".equals(tag) + || "sampleResult".equals(tag); + } + + private static SampleRecord parseSample(Element element) { + String label = element.getAttribute("lb"); + if (label.isEmpty()) { + label = element.getAttribute("label"); + } + boolean success = Boolean.parseBoolean(element.getAttribute("s")); + String rc = element.getAttribute("rc"); + String rm = element.getAttribute("rm"); + String responseData = childText(element, "responseData"); + String responseHeaders = childText(element, "responseHeader"); + if (responseHeaders.isEmpty()) { + responseHeaders = childText(element, "responseHeaders"); + } + + List sub = new ArrayList<>(); + NodeList children = element.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + Node node = children.item(i); + if (node instanceof Element && isSampleElement((Element) node)) { + sub.add(parseSample((Element) node)); + } + } + return new SampleRecord(label, success, rc, rm, responseData, responseHeaders, sub); + } + + private static String childText(Element parent, String tagName) { + NodeList nodes = parent.getElementsByTagName(tagName); + if (nodes.getLength() == 0) { + return ""; + } + Node node = nodes.item(0); + return node.getTextContent() == null ? "" : node.getTextContent(); + } + + static String readTextFile(File file) throws IOException { + return new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/regression/RegressionProtocolProfile.java b/src/test/java/com/blazemeter/jmeter/http2/regression/RegressionProtocolProfile.java new file mode 100644 index 0000000..bb2a1f7 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/regression/RegressionProtocolProfile.java @@ -0,0 +1,75 @@ +package com.blazemeter.jmeter.http2.regression; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** JVM properties passed to JMeter when exercising BlazeMeter HTTP under a fixed protocol stack. */ +public enum RegressionProtocolProfile { + + HTTP1_ONLY("http1-only", + "legacy", + true, + false, + false, + false), + + HTTP2("http2", + "browser-compatible", + true, + true, + false, + true), + + HTTP3("http3", + "browser-compatible", + true, + true, + true, + true); + + private final String id; + private final String profileName; + private final boolean enableHttp1; + private final boolean enableHttp2; + private final boolean enableHttp3; + private final boolean alpnEnabled; + + RegressionProtocolProfile(String id, String profileName, boolean enableHttp1, + boolean enableHttp2, boolean enableHttp3, boolean alpnEnabled) { + this.id = id; + this.profileName = profileName; + this.enableHttp1 = enableHttp1; + this.enableHttp2 = enableHttp2; + this.enableHttp3 = enableHttp3; + this.alpnEnabled = alpnEnabled; + } + + public String getId() { + return id; + } + + public Map jmeterProperties() { + Map props = new LinkedHashMap<>(); + props.put("blazemeter.http.profile", profileName); + props.put("blazemeter.http.enableHttp1", Boolean.toString(enableHttp1)); + props.put("blazemeter.http.enableHttp2", Boolean.toString(enableHttp2)); + props.put("blazemeter.http.enableHttp3", Boolean.toString(enableHttp3)); + props.put("blazemeter.http.alpnEnabled", Boolean.toString(alpnEnabled)); + props.put("blazemeter.http.fallbackEnabled", "true"); + if (this == HTTP1_ONLY) { + props.put("blazemeter.http.altSvcCacheEnabled", "false"); + props.put("blazemeter.http.h2cCacheEnabled", "false"); + } + return props; + } + + public static RegressionProtocolProfile fromSystemProperty() { + String value = System.getProperty("jmeter.regression.protocol", "http1-only"); + for (RegressionProtocolProfile profile : values()) { + if (profile.id.equalsIgnoreCase(value)) { + return profile; + } + } + return HTTP1_ONLY; + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/regression/SampleRecord.java b/src/test/java/com/blazemeter/jmeter/http2/regression/SampleRecord.java new file mode 100644 index 0000000..5eace75 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/regression/SampleRecord.java @@ -0,0 +1,64 @@ +package com.blazemeter.jmeter.http2.regression; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** Normalized view of one JMeter sample (and nested sub-samples). */ +public final class SampleRecord { + + private final String label; + private final boolean successful; + private final String responseCode; + private final String responseMessage; + private final String responseData; + private final String responseHeaders; + private final List children; + + public SampleRecord( + String label, + boolean successful, + String responseCode, + String responseMessage, + String responseData, + String responseHeaders, + List children) { + this.label = label; + this.successful = successful; + this.responseCode = responseCode; + this.responseMessage = responseMessage; + this.responseData = responseData == null ? "" : responseData; + this.responseHeaders = responseHeaders == null ? "" : responseHeaders; + this.children = children == null + ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(children)); + } + + public String getLabel() { + return label; + } + + public boolean isSuccessful() { + return successful; + } + + public String getResponseCode() { + return responseCode; + } + + public String getResponseMessage() { + return responseMessage; + } + + public String getResponseData() { + return responseData; + } + + public String getResponseHeaders() { + return responseHeaders; + } + + public List getChildren() { + return children; + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/sampler/JmxBlazeMeterHttpMigratorTest.java b/src/test/java/com/blazemeter/jmeter/http2/sampler/JmxBlazeMeterHttpMigratorTest.java new file mode 100644 index 0000000..94a3aca --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/sampler/JmxBlazeMeterHttpMigratorTest.java @@ -0,0 +1,96 @@ +package com.blazemeter.jmeter.http2.sampler; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import java.io.File; +import java.nio.file.Path; +import org.apache.jmeter.protocol.http.sampler.HTTPSamplerProxy; +import org.apache.jmeter.testelement.TestElement; +import org.apache.jorphan.collections.HashTree; +import org.junit.BeforeClass; +import org.junit.Test; + +public class JmxBlazeMeterHttpMigratorTest extends HTTP2TestBase { + + private static File testHttpJmx; + + @BeforeClass + public static void locateTestHttpJmx() { + Path path = Path.of("src", "test", "resources", "jmeter-regression", "5.6.3", "TEST_HTTP.jmx"); + testHttpJmx = path.toFile(); + assertTrue("TEST_HTTP.jmx must exist at " + path, testHttpJmx.isFile()); + } + + @Test + public void migratesAllApacheHttpSamplersInTestHttpPlan() throws Exception { + HashTree tree = JmxBlazeMeterHttpMigrator.loadTree(testHttpJmx); + int before = JmxBlazeMeterHttpMigrator.countMigratableSamplers(tree); + assertTrue(before > 0); + + JmxBlazeMeterHttpMigrator.MigrationResult result = + JmxBlazeMeterHttpMigrator.migrateTreeWithDetails(tree); + assertEquals(before, result.getReplacedCount()); + assertEquals(0, JmxBlazeMeterHttpMigrator.countMigratableSamplers(tree)); + assertEquals(before, JmxBlazeMeterHttpMigrator.countHttp2Samplers(tree)); + } + + @Test + public void preservesSamplerPropertiesDuringMigration() throws Exception { + HTTPSamplerProxy src = new HTTPSamplerProxy(); + src.setName("api-call"); + src.setDomain("example.org"); + src.setPort(443); + src.setPath("/v1/items"); + src.setMethod("POST"); + src.setFollowRedirects(true); + + HashTree tree = new org.apache.jorphan.collections.ListedHashTree(); + tree.add(src); + + JmxBlazeMeterHttpMigrator.migrateTree(tree); + Object migrated = tree.list().iterator().next(); + assertTrue(migrated instanceof HTTP2Sampler); + HTTP2Sampler dest = (HTTP2Sampler) migrated; + assertEquals("example.org", dest.getDomain()); + assertEquals(443, dest.getPort()); + assertEquals("/v1/items", dest.getPath()); + assertEquals("POST", dest.getMethod()); + assertTrue(dest.getFollowRedirects()); + assertEquals(HTTP2Sampler.class.getName(), dest.getPropertyAsString(TestElement.TEST_CLASS)); + assertFalse(dest.getPropertyAsString(TestElement.GUI_CLASS).contains("HTTPSampler")); + } + + @Test + public void migrateCopyPreservesChildElements() throws Exception { + HTTPSamplerProxy parent = new HTTPSamplerProxy(); + parent.setName("parent"); + org.apache.jmeter.assertions.ResponseAssertion assertion = + new org.apache.jmeter.assertions.ResponseAssertion(); + assertion.setName("assert-ok"); + HashTree tree = new org.apache.jorphan.collections.ListedHashTree(); + HashTree sub = tree.add(parent); + sub.add(assertion); + + HashTree migrated = JmxBlazeMeterHttpMigrator.migrateCopy(tree); + Object key = migrated.list().iterator().next(); + assertTrue(key instanceof HTTP2Sampler); + HashTree childTree = migrated.getTree(key); + assertEquals(1, childTree.list().size()); + assertTrue(childTree.list().iterator().next() + instanceof org.apache.jmeter.assertions.ResponseAssertion); + } + + @Test + public void migrateFileWritesNewJmx() throws Exception { + File target = File.createTempFile("migrated-", ".jmx"); + target.deleteOnExit(); + JmxBlazeMeterHttpMigrator.migrateFile(testHttpJmx, target); + + HashTree loaded = JmxBlazeMeterHttpMigrator.loadTree(target); + assertEquals(0, JmxBlazeMeterHttpMigrator.countMigratableSamplers(loaded)); + assertTrue(JmxBlazeMeterHttpMigrator.countHttp2Samplers(loaded) > 0); + } +} diff --git a/src/test/resources/jmeter-regression/5.6.3/BUG_62847.jmx b/src/test/resources/jmeter-regression/5.6.3/BUG_62847.jmx new file mode 100644 index 0000000..3413610 --- /dev/null +++ b/src/test/resources/jmeter-regression/5.6.3/BUG_62847.jmx @@ -0,0 +1,322 @@ + + + + + + false + true + true + + + + + + + + continue + + false + 1 + + 1 + 1 + false + + + + + + true + 8 + + + + ${__jm__loop__idx} == 3 + false + + + + false + true + false + + + + + + false + true + false + + + + + continue + + false + 1 + + 1 + 1 + false + + + + + + 1 + 0 + 0 + + + + + continue_loop + + + + true + + + false + + + + + ${continue_loop} + + + + ${__jm__while__idx} == 3 + false + + + + false + true + false + + + + 1 + 0 + 0 + + + + + continue_loop + + + + false + + + false + + + + + + + false + true + false + + + + + continue + + false + 1 + + 1 + 1 + false + + + + + + 1 + 0 + 0 + + + + + idx_matchNr + idx_1 + idx_2 + idx_3 + idx_4 + idx_5 + idx_6 + idx_7 + idx_8 + + + + 8 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + + + false + + + + + idx + currentIdx + true + + + + ${__jm__for_idx__idx} == 3 + false + + + + false + true + false + + + + 1 + 0 + 0 + + + + + continue_loop + + + + false + + + false + + + + + + + false + true + false + + + + + false + + saveConfig + + + false + false + true + + true + true + true + false + false + false + false + false + false + false + true + false + false + false + false + 0 + + + BUG_62847.csv + + + + false + + saveConfig + + + false + false + true + + true + true + true + false + false + true + true + false + false + true + false + false + false + false + false + 0 + + + BUG_62847.xml + + + + false + + saveConfig + + + true + true + true + + true + true + true + true + false + true + true + false + false + false + true + false + false + false + true + 0 + true + true + true + true + true + true + + + + + + + + diff --git a/src/test/resources/jmeter-regression/5.6.3/Bug54685.jmx b/src/test/resources/jmeter-regression/5.6.3/Bug54685.jmx new file mode 100644 index 0000000..1bd4d0e --- /dev/null +++ b/src/test/resources/jmeter-regression/5.6.3/Bug54685.jmx @@ -0,0 +1,155 @@ + + + + + + false + false + + + + REFERENCE + reference + = + + + JSESSIONID + jsessionId + = + + + + + + + + continue + + false + 1 + + 1 + 1 + 1364309240000 + 1364309240000 + false + + + + + + + + + Sleep_Time + 5 + = + + + Sleep_Mask + 0x03 + = + + + Label + sample_variables=${__P(sample_variables,'undef')} REFERENCE=${REFERENCE} JSESSIONID=${JSESSIONID} + = + + + ResponseCode + + = + + + ResponseMessage + + = + + + Status + OK + = + + + SamplerData + + = + + + ResultData + + = + + + + org.apache.jmeter.protocol.java.test.JavaTest + + + + + false + + saveConfig + + + false + false + true + + true + true + false + true + false + false + false + false + false + false + true + false + false + false + false + 0 + true + + + Bug54685.csv + + + + false + + saveConfig + + + false + false + true + + true + true + false + true + false + true + true + false + false + true + false + false + false + false + false + 0 + true + + + Bug54685.xml + + + + + diff --git a/src/test/resources/jmeter-regression/5.6.3/HTMLParserTestFile_2.jmx b/src/test/resources/jmeter-regression/5.6.3/HTMLParserTestFile_2.jmx new file mode 100644 index 0000000..6600b89 --- /dev/null +++ b/src/test/resources/jmeter-regression/5.6.3/HTMLParserTestFile_2.jmx @@ -0,0 +1,124 @@ + + + + + + false + false + + + + + + + + continue + + false + 1 + + 1 + 1 + 1317685259000 + 1317685259000 + false + + + + + + + + + + + file + + testfiles/HTMLParserTestFile_2.html + GET + false + false + false + false + true + + + + + + + + false + + saveConfig + + + false + false + true + + true + true + true + true + true + true + true + false + true + true + false + true + true + false + true + 0 + true + true + true + true + true + + + HTMLParserTestFile_2.xml + + + + false + + saveConfig + + + true + false + true + + true + true + true + true + true + false + false + false + false + false + true + false + false + false + true + 0 + true + true + true + true + true + + + HTMLParserTestFile_2.csv + + + + + diff --git a/src/test/resources/jmeter-regression/5.6.3/Http4ImplDigestAuth.jmx b/src/test/resources/jmeter-regression/5.6.3/Http4ImplDigestAuth.jmx new file mode 100644 index 0000000..0f7e250 --- /dev/null +++ b/src/test/resources/jmeter-regression/5.6.3/Http4ImplDigestAuth.jmx @@ -0,0 +1,218 @@ + + + + + + false + true + + + + has_auth_header + false + = + + + + + + + + + + + jmeter.apache.org + + + + / + 6 + + + + + + 200 + + + + continue + + false + 1 + + 1 + 0 + 1485612143000 + 1485612143000 + false + + + + + + + + https://jigsaw.w3.org/HTTP/Digest/ + guest + guest + + test + DIGEST + + + http://httpbin.org/digest-auth/ + user + passwd + + me@kennethreitz.com + DIGEST + + + + + + + + + jigsaw.w3.org + + https + + /HTTP/Digest/ + GET + true + false + true + false + + + + + + + + Digest Authentication test page + + Assertion.response_data + false + 16 + + + + + + + + + httpbin.org + + http + + /digest-auth/auth/user/passwd + GET + true + false + true + false + + + + + + + authenticated + $.authenticated + 1 + nv_authenticated + + + + + true + + Assertion.response_data + false + 16 + + variable + authenticated + + + + + + Authorization: Digest + + + Assertion.request_headers + false + 16 + + + + + false + + saveConfig + + + false + false + true + + true + true + true + true + false + true + true + false + false + false + true + false + false + false + false + 0 + + + Http4ImplDigestAuth.csv + + + + false + + saveConfig + + + false + false + true + + true + true + true + true + false + true + true + false + false + true + false + false + false + false + false + 0 + + + Http4ImplDigestAuth.xml + + + + + diff --git a/src/test/resources/jmeter-regression/5.6.3/Http4ImplPreemptiveBasicAuth.jmx b/src/test/resources/jmeter-regression/5.6.3/Http4ImplPreemptiveBasicAuth.jmx new file mode 100644 index 0000000..970baf5 --- /dev/null +++ b/src/test/resources/jmeter-regression/5.6.3/Http4ImplPreemptiveBasicAuth.jmx @@ -0,0 +1,471 @@ + + + + + + false + true + + + + has_auth_header + false + = + + + + + + + + + + + jmeter.apache.org + + https + + / + 6 + 2000 + 5000 + + + + 200 + + + + continue + + false + 2 + + 1 + 0 + 1485612143000 + 1485612143000 + false + + + + + + + + http://localhost:8081/ + ${login} + ${password} + + * + + + + + + + + + + + + + / + GET + true + false + true + false + + + + + + + ; + + Http4ImplPreemptiveBasicAuth-data.csv + false + false + true + shareMode.group + false + login,password + + + + + continue + + false + 2 + + 1 + 0 + 1485612143000 + 1485612143000 + false + + + + + + + + http://localhost:8081/ + ${login} + ${password} + + * + + + + + + ; + + Http4ImplPreemptiveBasicAuth-data.csv + false + false + true + shareMode.group + false + login,password + + + + + + + + + https + + / + GET + true + false + true + false + + + + + + + + continue + + false + 2 + + 1 + 0 + 1485612143000 + 1485612143000 + false + + + + + + + + + + + https + + / + GET + true + false + true + false + + + + + + + + continue + + false + 2 + + 1 + 0 + 1485612143000 + 1485612143000 + false + + + + + + + + + + + https + + / + GET + true + false + true + false + + + + + + + + continue + + false + 2 + + 1 + 0 + 1485612143000 + 1485612143000 + false + + + + + + + has_auth_header + + + + true + + + false + + + + + + https://jmeter.apache.org/ + ${login} + ${password} + + * + + + + + + ; + + Http4ImplPreemptiveBasicAuth-data.csv + false + false + true + shareMode.group + false + login,password,headerValue + + + + + + + + + + + / + GET + true + false + true + false + + + + + + + + continue + + false + 2 + + 1 + 0 + 1485612143000 + 1485612143000 + false + + + + + + + has_auth_header + + + + true + + + false + + + + + + https://jmeter.apache.org/ + ${login} + ${password} + + * + + + + + + ; + + Http4ImplPreemptiveBasicAuth-data.csv + false + false + true + shareMode.group + false + login,password,headerValue + + + + + + + + + https + + / + GET + true + false + true + false + + + + + + + + 9796e1f7-1b35-434c-b29f-36c589b72ea9 + + + boolean mustHaveAuthHeader = vars["has_auth_header"].toBoolean(); +String requestHeaders = SampleResult.getRequestHeaders(); +if(mustHaveAuthHeader) { + if(requestHeaders.contains("Authorization")) { + if(requestHeaders.contains("Authorization: Basic "+vars["headerValue"])) { + AssertionResult.setFailure(false); + } else { + AssertionResult.setFailure(true); + AssertionResult.setFailureMessage("Wrong authorization header value:"+requestHeaders); + } + } else { + AssertionResult.setFailure(true); + AssertionResult.setFailureMessage("Request has no authorization header while it must have:"+requestHeaders); + } +} else { + if(requestHeaders.contains("Authorization")) { + AssertionResult.setFailure(true); + AssertionResult.setFailureMessage("Request has authorization header while it shouldn't have any:"+requestHeaders); + } +} + + groovy + + + + false + + saveConfig + + + false + false + true + + true + true + true + true + false + true + true + false + false + false + true + false + false + false + false + 0 + + + Http4ImplPreemptiveBasicAuth.csv + + + + false + + saveConfig + + + false + false + true + + true + true + true + true + false + true + true + false + false + true + false + false + false + false + false + 0 + + + Http4ImplPreemptiveBasicAuth.xml + + + + + true + + + + 8081 + 0 + 25 + + + + + diff --git a/src/test/resources/jmeter-regression/5.6.3/ResponseDecompression.jmx b/src/test/resources/jmeter-regression/5.6.3/ResponseDecompression.jmx new file mode 100644 index 0000000..9350526 --- /dev/null +++ b/src/test/resources/jmeter-regression/5.6.3/ResponseDecompression.jmx @@ -0,0 +1,249 @@ + + + + + + false + false + + + + + + + + + + + + + https + + + 6 + 5000 + 30000 + + + + + + User-Agent + Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11; rv:52.0) Gecko/20100101 Firefox/52.0 + + + Accept + text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 + + + Accept-Language + en-US,en;q=0.5 + + + Cache-Control + max-age=0 + + + + + + continue + + false + 1 + + 1 + 1 + 1493581595000 + 1493581595000 + false + + + false + + + + + + + Content-Encoding: deflate + content-encoding: deflate + + Assertion.response_headers + false + 48 + + + + + + + Accept-Encoding + deflate + + + + + + + + + www.bing.com + + + + / + GET + true + false + true + false + + + + + + + + Microsoft + bing + + Assertion.response_data + false + 16 + + + + + + + 1 + 0 + 0 + + + + 500 + 100 + + + + + + + + Content-Encoding: br + content-encoding: br + + Assertion.response_headers + false + 48 + + + + + + + Accept-Encoding + br + + + + + + + + + www.facebook.com + + + + / + GET + true + false + true + false + + + + + + + + facebook + + Assertion.response_data + false + 16 + + + + + + + + false + + saveConfig + + + false + false + true + + true + true + true + true + false + false + false + false + false + false + false + false + false + false + false + 0 + + + ResponseDecompression.csv + + + + false + + saveConfig + + + false + false + true + + true + true + false + true + false + true + true + false + false + true + false + false + false + false + false + 0 + + + ResponseDecompression.xml + + + + + diff --git a/src/test/resources/jmeter-regression/5.6.3/SlowCharsFeature.jmx b/src/test/resources/jmeter-regression/5.6.3/SlowCharsFeature.jmx new file mode 100644 index 0000000..17a8e2e --- /dev/null +++ b/src/test/resources/jmeter-regression/5.6.3/SlowCharsFeature.jmx @@ -0,0 +1,383 @@ + + + + + + false + false + + + + + + + + + + Range + bytes=0-7000 + + + + + + continue + + false + 1 + + 1 + 1 + 1485469603000 + 1485469603000 + false + + + + + + true + + + import org.apache.jmeter.util.JMeterUtils; + +JMeterUtils.setProperty("httpclient.socket.http.cps","1500"); +JMeterUtils.setProperty("httpclient.socket.https.cps","1500"); +SampleResult.setBytes(0); + groovy + + + + + continue + + false + 1 + + 1 + 1 + 1485465167000 + 1485465167000 + false + + + + + + true + + + if(SampleResult.getTime() < 5000) { + AssertionResult.setFailure(true); + AssertionResult.setFailureMessage("Sampler should have taken more than 5 seconds"); +} + + groovy + + + + SizeAssertion.response_data + 7001 + 1 + + + + + 206 + + Assertion.response_code + false + 8 + + + + + + HTTP/1.1 206 Partial [Cc]ontent + + Assertion.response_headers + false + 2 + + + + + + + + + + jmeter.apache.org + + https + + + 6 + 3000 + 10000 + + + + ${__jexl3("${__P(jmeter.httpsampler,)}" != "Java",)} + false + true + Java implementation does not support slow chars for HTTP + + + + + + + + + + + / + GET + true + false + true + false + + + + + + + + + + + + + https + + / + GET + true + false + true + false + + + + + + + + Apache JMeter + + Assertion.response_data + false + 16 + + + + + + + + + + + analytics.usa.gov + + + + + 6 + 3000 + 10000 + + + + + + + + + https + + + GET + true + false + true + false + + HttpClient4 + + + + + + + <meta property="og:title" content="analytics.usa.gov | The US government's web traffic." /> + + Assertion.response_data + false + 16 + + + + + + javax.net.ssl.SSLHandshakeException + handshake_failure + + Assertion.response_data + false + 20 + + + + + + + + + + + https + + + GET + true + false + true + false + + HttpClient4 + 15000 + 60000 + + + + + <meta property="og:title" content="analytics.usa.gov | The US government's web traffic." /> + + Assertion.response_data + false + 16 + + + + + + javax.net.ssl.SSLHandshakeException + handshake_failure + + Assertion.response_data + false + 20 + + + + + + + + false + + saveConfig + + + false + false + true + + true + true + true + true + false + false + false + false + false + false + true + false + false + false + false + 0 + + + SlowCharsFeature_${__property(jmeter.httpsampler,,HttpClient4)}.csv + + + + false + + saveConfig + + + false + false + true + + true + true + true + true + false + true + true + false + false + true + false + false + false + false + false + 0 + + + SlowCharsFeature_${__property(jmeter.httpsampler,,HttpClient4)}.xml + + + + true + + saveConfig + + + true + true + true + + true + true + true + true + true + true + true + true + true + true + false + true + true + false + true + 0 + true + true + true + true + true + true + true + true + true + + + SlowCharsFeature_${__property(jmeter.httpsampler,,HttpClient4)}-errors.xml + + + + + diff --git a/src/test/resources/jmeter-regression/5.6.3/TEST_GET.jmx b/src/test/resources/jmeter-regression/5.6.3/TEST_GET.jmx new file mode 100644 index 0000000..162c30a --- /dev/null +++ b/src/test/resources/jmeter-regression/5.6.3/TEST_GET.jmx @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/test/resources/jmeter-regression/5.6.3/TEST_HTTP.jmx b/src/test/resources/jmeter-regression/5.6.3/TEST_HTTP.jmx new file mode 100644 index 0000000..da93b79 --- /dev/null +++ b/src/test/resources/jmeter-regression/5.6.3/TEST_HTTP.jmx @@ -0,0 +1,1657 @@ + + + + + + false + true + + + + + true + + + + stoptest + + false + 1 + + 1 + 1 + 1488116764000 + 1488116764000 + false + + + + + + true + + + import org.apache.jmeter.protocol.http.control.HttpMirrorServer; + +HttpMirrorServer mirrorServer = new HttpMirrorServer(8081, 10, 10); +props.put("MIRROR_SERVER", mirrorServer); + +mirrorServer.start(); + groovy + + + + + continue + + false + 1 + + 1 + 1 + 1488116271000 + 1488116271000 + false + + + + + + + + + + + + + /test + GET + true + false + true + false + + + + + + + + GET /test HTTP/1.1 + Connection: keep-alive + Host: localhost:8081 + User-Agent: + + Assertion.response_data + false + 16 + + + + + + Content-Length: + Content-Type: + + Assertion.request_headers + false + 20 + + + + + + + + + TEST_GET.jmx + file + text/xml + + + + + + + + + + + /test?name=value + GET + true + false + true + false + + + + + + + + Content-Length: + Content-Type: + + Assertion.request_headers + false + 20 + + + + + + GET + Connection: keep-alive + Host: localhost:8081 + User-Agent: + /test?name=value + + Assertion.response_data + false + 16 + + + + + + + + + false + value1 + = + true + name1 + + + + + + + + /test?name0=value0 + GET + true + false + true + false + + + + + + + + Content-Length: + Content-Type: + + Assertion.request_headers + false + 20 + + + + + + GET + Connection: keep-alive + Host: localhost:8081 + User-Agent: + /test?name0=value0&name1=value1 + + Assertion.response_data + false + 16 + + + + + + ${__jexl3("${__P(jmeter.httpsampler,)}" != "Java",)} + false + true + + + + true + + + + false + Get with body + = + + + + + + + + /test + GET + true + false + true + false + + + + + + + + Content-Length: 13 + Content-Type: text/plain + Content-Type: + + Assertion.request_headers + false + 16 + + + + + + GET /test HTTP/1.1 + Connection: keep-alive + Host: localhost:8081 + User-Agent: + Get with body + + Assertion.response_data + false + 16 + + + + + + + + + + localhost + 8081 + + + + 6 + 3000 + 30000 + + + + + continue + + false + 1 + + 1 + 1 + 1488116271000 + 1488116271000 + false + + + + + + + + + www.example.org + + + + + 6 + 3000 + 30000 + + + + + + + + + + + / + GET + true + false + true + false + + + + + + + + + Accept-Encoding + gzip + + + + + + + Accept-Encoding: gzip + + Assertion.request_headers + false + 16 + + + + + + Content-Length: + Content-Type: + + Assertion.request_headers + false + 20 + + + + + + Example Domain + + Assertion.response_data + false + 16 + + + + + + + + + + + + + / + GET + true + false + true + false + + + + + + + + Content-Encoding: gzip + + Assertion.response_headers + false + 20 + + + + + + Content-Length: + Content-Type: + + Assertion.request_headers + false + 20 + + + + + + Accept-Encoding: gzip + + Assertion.request_headers + false + 20 + + + + + + Example Domain + + Assertion.response_data + false + 16 + + + + + + + continue + + false + 1 + + 1 + 1 + 1488116271000 + 1488116271000 + false + + + + + + + + + localhost + 8081 + http + + + 6 + 3000 + 30000 + + + + + + + + + + + / + GET + false + false + true + false + + + + + + + + + Accept-Encoding + gzip + + + X-SetHeaders + Location:http://localhost:8081/redirect + + + X-ResponseStatus + 302 + + + + + + + Accept-Encoding: gzip + + Assertion.request_headers + false + 16 + + + + + + Content-Length: + Content-Type: + + Assertion.request_headers + false + 20 + + + + + + ${__jexl3("${__P(jmeter.httpsampler,)}" != "Java",)} + false + true + + + + true + + + + false + Method has body + = + + + + + + + + / + GET + false + false + true + false + + + + BUG 60682 + + + + + + Accept-Encoding + gzip + + + X-SetHeaders + Location:http://localhost:8081/redirect + + + X-ResponseStatus + 302 + + + + + + + Accept-Encoding: gzip + + Assertion.request_headers + false + 16 + + + + + + Content-Length: + Content-Type: + + Assertion.request_headers + false + 16 + + + + + + + true + + + + false + We have a body + = + + + + + + + + / + GET + true + false + true + false + + + + + + + + + Accept-Encoding + gzip + + + X-SetHeaders + Location:http://localhost:8081/redirect + + + X-ResponseStatus + 302 + + + + + + + Exceeded maximum number of redirects + + Assertion.response_message + true + 16 + + + + + + Content-Length: + Content-Type: + + Assertion.request_headers + false + 20 + + + + + + + + + + + + + / + GET + false + true + true + false + + + + + + + + + Accept-Encoding + gzip + + + X-SetHeaders + Location:http://localhost:8081/redirect + + + X-ResponseStatus + 302 + + + + + + + Non HTTP response message + + Assertion.response_message + true + 16 + + + + + + + continue + + false + 1 + + 1 + 1 + 1488116271000 + 1488116271000 + false + + + + + + + + + localhost + 8081 + http + + + 6 + 3000 + 30000 + + + + + + + false + value1 + = + true + name1 + + + + + + + + /test?name0=value0 + POST + false + false + true + false + + + + + + + + + Accept-Encoding + gzip + + + + + + + POST /test?name0=value0 HTTP/1.1 + Connection: keep-alive + Accept-Encoding: gzip + Content-Length: 12 + Host: localhost:8081 + User-Agent: + + Assertion.response_data + false + 16 + + + + + + + + + false + value1 + = + true + name1 + + + + + + + + /test?name0=value0 + POST + false + false + true + true + + + + + + + + + Accept-Encoding + gzip + + + + + + false + boundary + boundary=(.*) + $1$ + nv_boundary + 1 + + + + + POST /test?name0=value0 HTTP/1.1 + Connection: keep-alive + Accept-Encoding: gzip + Host: localhost:8081 + User-Agent: + Content-Type: multipart/form-data; boundary= + Content-Disposition: form-data; name="name1" + Content-Type: text/plain + Content-Transfer-Encoding: 8bit + value1 + boundary=${boundary} + ${boundary} + + Assertion.response_data + false + 16 + + + + + + + + + false + value1 + = + true + name1 + + + false + value2 + = + true + 安_param + + + + + + + UTF-8 + /test?name0=value0 + POST + false + false + true + true + true + + + + + + + + + Accept-Encoding + gzip + + + + + + false + boundary + boundary=(.*) + $1$ + nv_boundary + 1 + + + + + POST /test?name0=value0 HTTP/1.1 + Connection: keep-alive + Accept-Encoding: gzip + Host: localhost:8081 + User-Agent: + Content-Type: multipart/form-data; boundary= + Content-Disposition: form-data; name="name1" + value1 + boundary=${boundary} + ${boundary} + + Assertion.response_data + false + 16 + Content-Type: multipart/form-data; boundary= + + + + + Workaround as Mirror Server corrupts encoding + true + + + String textToCheck = 'Content-Disposition: form-data; name="安_param"'; +if(prev.getSamplerData().indexOf(textToCheck) < 0) { + AssertionResult.setFailure(true); + AssertionResult.setFailureMessage("Request does not contains '"+textToCheck+"'"); +} + + groovy + + + + + true + + + + false + Veni vidi vici + = + + + + + + + + /test?name0=value0 + POST + false + false + true + false + + + + + + + + + Accept-Encoding + gzip + + + + + + request_headers + contentType + Content-Type: (.*) + $1$ + nv_contentType + 1 + + + + + POST /test?name0=value0 HTTP/1.1 + Connection: keep-alive + Accept-Encoding: gzip + Content-Length: 14 + Host: localhost:8081 + User-Agent: + Veni vidi vici + + Assertion.response_data + false + 16 + + + + + + nv_contentType + text/plain; charset=UTF-8 + + Assertion.response_data + false + 40 + + variable + contentType + + + + + + + + user.properties + fileName + text/plain + + + + + + + + + + + /test?name0=value0 + POST + false + false + true + false + + + + + + + + + Accept-Encoding + gzip + + + + + + false + boundary + boundary=(.*) + $1$ + nv_boundary + 1 + + + + + POST /test?name0=value0 HTTP/1.1 + Connection: keep-alive + Accept-Encoding: gzip + Host: localhost:8081 + User-Agent: + Content-Type: multipart/form-data; boundary= + Content-Disposition: form-data; name="fileName"; filename="user.properties" + Content-Type: text/plain + Content-Transfer-Encoding: binary + boundary=${boundary} + ${boundary} + + Assertion.response_data + false + 16 + + + + + + + + + user.properties + + text/plain + + + + + + + + + + + /test?name0=value0 + POST + false + false + true + false + + + + + + + + + Accept-Encoding + gzip + + + + + + + Content-Type: multipart/form-data; boundary= + Content-Disposition: form-data; name="fileName"; filename="user.properties" + Content-Transfer-Encoding: binary + boundary= + + Assertion.response_data + false + 20 + + + + + + Sample user.properties file + Host: localhost:8081 + User-Agent: + + Assertion.response_data + false + 16 + + + + + + Connection: keep-alive + Accept-Encoding: gzip + Content-Type: text/plain + Content-Length: + + Assertion.request_headers + false + 16 + + + + + + + + + user.properties + + text/plain + + + + + + + + + + + /test?name0=value0 + POST + false + false + true + false + + + + + + + + + Accept-Encoding + gzip + + + Content-Type + text/plain + + + + + + + Content-Type: multipart/form-data; boundary= + Content-Disposition: form-data; name="fileName"; filename="user.properties" + Content-Transfer-Encoding: binary + boundary= + PUT http://localhost:8081/test?name0=value0 + + Assertion.response_data + false + 20 + + + + + + Sample user.properties file + User-Agent: + Host: localhost:8081 + + Assertion.response_data + false + 16 + + + + + + Connection: keep-alive + Accept-Encoding: gzip + Content-Type: text/plain + Content-Length: + + Assertion.request_headers + false + 16 + + + + + + + + + user.properties + + text/plain + + + + + + + + + + + /test?name0=value0 + PUT + false + false + true + false + + + + + + + + + Accept-Encoding + gzip + + + Content-Type + text/plain + + + + + + + Content-Type: multipart/form-data; boundary= + Content-Disposition: form-data; name="fileName"; filename="user.properties" + Content-Transfer-Encoding: binary + boundary= + + Assertion.response_data + false + 20 + + + + + + Sample user.properties file + PUT /test?name0=value0 HTTP/1.1 + Host: localhost:8081 + User-Agent: + + Assertion.response_data + false + 16 + + + + + + Connection: keep-alive + Accept-Encoding: gzip + Content-Type: text/plain + Content-Length: + + Assertion.request_headers + false + 16 + + + + + + true + + + + false + Body of Put + = + + + + + + + + /test?name0=value0 + PUT + false + false + true + false + + + + + + + + + Accept-Encoding + gzip + + + Content-Type + text/plain + + + + + + + Content-Type: multipart/form-data; boundary= + Content-Disposition: form-data; name="fileName"; filename="user.properties" + Content-Transfer-Encoding: binary + boundary= + + Assertion.response_data + false + 20 + + + + + + PUT /test?name0=value0 HTTP/1.1 + Body of Put + User-Agent: + + Assertion.response_data + false + 16 + + + + + + Connection: keep-alive + Accept-Encoding: gzip + Content-Type: text/plain + Content-Length: + + Assertion.request_headers + false + 16 + + + + + + ${__jexl3("${__P(jmeter.httpsampler,)}" == "Java",)} + false + true + + + + + + + false + value1 + = + true + name1 + + + + + + + + /test?name0=value0 + PUT + false + false + true + false + + + + + + + + + Accept-Encoding + gzip + + + + + + + Content-Type: multipart/form-data; boundary= + Content-Disposition: form-data; name="fileName"; filename="user.properties" + Content-Transfer-Encoding: binary + boundary= + Content-Type: text/plain + + Assertion.response_data + false + 20 + + + + + + PUT /test?name0=value0 HTTP/1.1 + Host: localhost:8081 + User-Agent: + + Assertion.response_data + false + 16 + + + + + + Connection: keep-alive + Accept-Encoding: gzip + + Assertion.request_headers + false + 16 + + + + + + + ${__jexl3("${__P(jmeter.httpsampler,)}" != "Java",)} + false + true + + + + + + + false + value1 + = + true + name1 + + + + + + + + /test?name0=value0 + PUT + false + false + true + false + + + + + + + + + Accept-Encoding + gzip + + + + + + + Content-Type: multipart/form-data; boundary= + Content-Disposition: form-data; name="fileName"; filename="user.properties" + Content-Transfer-Encoding: binary + boundary= + Content-Type: text/plain + + Assertion.response_data + false + 20 + + + + + + PUT /test?name0=value0 HTTP/1.1 + Host: localhost:8081 + User-Agent: + name1=value1 + + Assertion.response_data + false + 16 + + + + + + Connection: keep-alive + Accept-Encoding: gzip + + Assertion.request_headers + false + 16 + + + + + + + + stoptest + + false + 1 + + 1 + 1 + 1488116768000 + 1488116768000 + false + + + + + + groovy + + + true + import org.apache.jmeter.protocol.http.control.HttpMirrorServer; + +HttpMirrorServer mirrorServer = (HttpMirrorServer) props.get("MIRROR_SERVER"); +mirrorServer.stopServer(); + + + + + false + + saveConfig + + + false + false + true + + true + false + true + true + false + false + false + false + false + false + false + false + false + false + false + 0 + + + TEST_HTTP_${__P(jmeter.httpsampler,)}.csv + + + + false + + saveConfig + + + false + false + true + + true + false + false + true + false + true + true + false + false + true + false + false + false + false + false + 0 + + + TEST_HTTP_${__P(jmeter.httpsampler,)}.xml + + + + + diff --git a/src/test/resources/jmeter-regression/5.6.3/TEST_HTTPS.jmx b/src/test/resources/jmeter-regression/5.6.3/TEST_HTTPS.jmx new file mode 100644 index 0000000..6ffa072 --- /dev/null +++ b/src/test/resources/jmeter-regression/5.6.3/TEST_HTTPS.jmx @@ -0,0 +1,194 @@ + + + + + + false + false + + + + + + + + continue + + false + 1 + + 1 + 1 + 1450720695000 + 1450720695000 + false + + + + + + + + + jmeter.apache.org + + https + + + GET + true + false + true + false + + HttpClient4 + + + + + + + Apache JMeter + + Assertion.response_data + false + 16 + + + + + javax.net.ssl.SSLHandshakeException + handshake_failure + + Assertion.response_data + false + 20 + + + + + + + + www.apache.org + + https + + + GET + true + false + true + false + + + + + + + + false + + saveConfig + + + false + false + true + + true + true + true + true + false + false + false + false + false + false + true + false + false + false + false + 0 + true + true + + + TEST_HTTPS.csv + + + + false + + saveConfig + + + false + false + true + + true + true + true + true + false + true + true + false + false + true + false + false + false + false + false + 0 + true + true + + + TEST_HTTPS.xml + + + + true + + saveConfig + + + false + false + true + + true + true + true + true + false + true + true + true + false + true + false + false + false + false + false + 0 + true + true + + + TEST_HTTPS.err + + + + + true + + + + diff --git a/src/test/resources/jmeter-regression/5.6.3/TestCookieManager.jmx b/src/test/resources/jmeter-regression/5.6.3/TestCookieManager.jmx new file mode 100644 index 0000000..9f6c551 --- /dev/null +++ b/src/test/resources/jmeter-regression/5.6.3/TestCookieManager.jmx @@ -0,0 +1,628 @@ + + + + + + false + true + true + + + + + + + + + + + localhost + 8081 + + + + 6 + + + + + + stoptest + + false + 1 + + 1 + 1 + 1488116764000 + 1488116764000 + false + + + + + + 877b8f57-5272-402c-bc11-cddbd4e69d71 + + + import org.apache.jmeter.protocol.http.control.HttpMirrorServer; + +HttpMirrorServer mirrorServer = new HttpMirrorServer(8081, 10, 10); +props.put("MIRROR_SERVER", mirrorServer); + +mirrorServer.start(); + groovy + + + + + continue + + false + 1 + + 1 + 1 + false + + + + + + + + value1 + localhost + / + false + 0 + true + true + + + value3 + localhost + / + true + 0 + true + true + + + value2 + localhost + / + false + 0 + true + true + + + true + + + + + + + localhost + 8081 + + + / + GET + true + false + true + false + + + + + + + + Cookie: myCookie=value1; myCookie2=value2 + + Assertion.response_data + false + 16 + + + + + Cookie Data: + myCookie=value1; myCookie2=value2 + + Assertion.request_data + false + 16 + + + + + + + + localhost + 8082 + https + + / + GET + true + false + true + false + + + + + + + + Cookie Data: + myCookie=value1; mySecureCookie=value3; myCookie2=value2 + + Assertion.request_data + true + 16 + + + + + + continue + + false + 1 + + 1 + 1 + false + + + + + + + + value1 + localhost + / + false + 0 + true + true + + + value3 + localhost + / + true + 0 + true + true + + + value2 + localhost + / + false + 0 + true + true + + + true + + + + + + + + + + + + 6 + Java + + + + + + + + + localhost + 8081 + + + / + GET + true + false + true + false + + + + + + + + Cookie: myCookie=value1; myCookie2=value2 + + Assertion.response_data + false + 16 + + + + + Cookie Data: + myCookie=value1; myCookie2=value2 + + Assertion.request_data + false + 16 + + + + + + + + localhost + 8082 + https + + / + GET + true + false + true + false + + + + + + + + Cookie Data: + myCookie=value1; mySecureCookie=value3; myCookie2=value2 + + Assertion.request_data + true + 16 + + + + + + continue + + false + 1 + + 1 + 1 + false + + + + + + + + Cookie + myCookie=value1; myCookie2=value2 + + + + + + + + + localhost + 8081 + + + / + GET + true + false + true + false + + + + + + + + Cookie Data: + myCookie=value1; myCookie2=value2 + + Assertion.request_data + false + 16 + + + + + Cookie: myCookie=value1; myCookie2=value2 + + Assertion.response_data + false + 16 + + + + + + + + localhost + 8082 + https + + / + GET + true + false + true + false + + + + + + + + + Cookie + myCookie=value1; mySecureCookie=value3; myCookie2=value2 + + + + + + + Cookie Data: + myCookie=value1; mySecureCookie=value3; myCookie2=value2 + + Assertion.request_data + true + 16 + + + + + + continue + + false + 1 + + 1 + 1 + false + + + + + + + + Cookie + myCookie=value1; myCookie2=value2 + + + + + + + + + + + + + + 6 + Java + + + + + + + + + localhost + 8081 + + + / + GET + true + false + true + false + + + + + + + + Cookie: myCookie=value1; myCookie2=value2 + + Assertion.response_data + false + 16 + + + + + Cookie Data: + myCookie=value1; myCookie2=value2 + + Assertion.request_data + false + 16 + + + + + + + + localhost + 8082 + https + + / + GET + true + false + true + false + + + + + + + + + Cookie + myCookie=value1; mySecureCookie=value3; myCookie2=value2 + + + + + + + Cookie Data: + myCookie=value1; mySecureCookie=value3; myCookie2=value2 + + Assertion.request_data + true + 16 + + + + + + stoptest + + false + 1 + + 1 + 1 + 1488116768000 + 1488116768000 + false + + + + + + groovy + + + 6e41dc10-a2fc-4d6b-a7a2-e76a1ceb22c5 + import org.apache.jmeter.protocol.http.control.HttpMirrorServer; + +HttpMirrorServer mirrorServer = (HttpMirrorServer) props.get("MIRROR_SERVER"); +mirrorServer.stopServer(); + + + + + false + + saveConfig + + + false + false + true + + true + false + true + true + false + false + false + false + false + false + true + false + false + false + false + 0 + true + true + + + TestCookieManager.csv + + + + false + + saveConfig + + + false + false + true + + true + false + true + true + false + true + true + false + false + true + false + false + false + false + false + 0 + true + true + + + TestCookieManager.xml + + + + + true + + + + 8081 + 0 + 25 + + + + + diff --git a/src/test/resources/jmeter-regression/5.6.3/TestHeaderManager.jmx b/src/test/resources/jmeter-regression/5.6.3/TestHeaderManager.jmx new file mode 100644 index 0000000..8f1f0f4 --- /dev/null +++ b/src/test/resources/jmeter-regression/5.6.3/TestHeaderManager.jmx @@ -0,0 +1,372 @@ + + + + + Test Header Manager merge feature + false + true + true + + + + + + + + + + Header1 + val1 + + + Header2 + val2 + + + Header3 + val3 + + + + + + + + + localhost + 8081 + + + + 6 + + + + + + continue + + false + 1 + + 1 + 1 + false + + + + + + test-header-manager-mirror + + + import org.apache.jmeter.protocol.http.control.HttpMirrorServer; + +HttpMirrorServer mirrorServer = new HttpMirrorServer(8081, 10, 10); +props.put("MIRROR_SERVER", mirrorServer); + +mirrorServer.start(); + groovy + + + + + continue + + false + 1 + + 1 + 1 + 1487511062000 + 1487511062000 + false + + + + + + + + Header5 + val5 + + + + + + + + + + + + + + GET + true + false + true + false + + + + + + + + + Header1 + val1_0 + + + Header2 + val2_0_overriden + + + Header4 + val4_0 + + + + + + + Header1: val1_0\b + + Header2: val2_0_overriden\b + Header4: val4_0\b + Header5: val5\b + Header3: val3\b + + Assertion.request_headers + true + 2 + + + + + + + + + + + + + + GET + true + false + true + false + + + + + + + + + Header1 + val1_1 + + + Header2 + val2_1_overriden + + + Header4 + val4_1 + + + + + + + Header1: val1_1\b + Header2: val2_1_overriden\b + Header4: val4_1\b + Header5: val5\b + Header3: val3\b + + Assertion.request_headers + true + 2 + + + + + + Header1: val1\b + Header2: val2\b + + Assertion.request_headers + true + 6 + + + + + + + continue + + false + 1 + + 1 + 1 + 1487511062000 + 1487511062000 + false + + + + + + + + + + + + + + GET + true + false + true + false + + + + + + + + Header1: val1\b + Header2: val2\b + Header3: val3\b + + Assertion.request_headers + true + 2 + + + + + + Header4 + Header5 + + Assertion.request_headers + true + 20 + + + + + + + continue + + false + 1 + + 1 + 1 + false + + + + + + test-header-manager-mirror-stop + + + import org.apache.jmeter.protocol.http.control.HttpMirrorServer; + +HttpMirrorServer mirrorServer = (HttpMirrorServer) props.get("MIRROR_SERVER"); +if (mirrorServer != null) { + mirrorServer.stopServer(); +} + groovy + + + + + + Connection: keep-alive + + Assertion.request_headers + false + 16 + + + + + false + + saveConfig + + + false + false + true + + false + false + true + true + false + false + false + false + false + false + false + false + false + false + false + 0 + + + TestHeaderManager.csv + + + + false + + saveConfig + + + false + false + true + + false + false + false + true + false + true + true + false + false + true + false + false + false + false + false + 0 + + + TestHeaderManager.xml + + + + + diff --git a/src/test/resources/jmeter-regression/5.6.3/TestKeepAlive.jmx b/src/test/resources/jmeter-regression/5.6.3/TestKeepAlive.jmx new file mode 100644 index 0000000..a6c580f --- /dev/null +++ b/src/test/resources/jmeter-regression/5.6.3/TestKeepAlive.jmx @@ -0,0 +1,465 @@ + + + + + + false + false + + + + validationAfterInactivity + 2000 + = + + + ttl + 30000 + = + + + + + + + + + + + jmeter.apache.org + + https + + + jmeter.apache.org + true + 6 + 3000 + 10000 + + + + + false + + + + continue + + false + 1 + + 1 + 1 + 1487437592000 + 1487437592000 + false + + + + + + true + + + import org.apache.jmeter.util.JMeterUtils; +int validationAfterInactivity = vars["validationAfterInactivity"].toInteger(); +int ttl = vars["ttl"].toInteger(); + +JMeterUtils.setProperty("httpclient4.validate_after_inactivity", validationAfterInactivity.toString()); +JMeterUtils.setProperty("httpclient4.time_to_live", ttl.toString()); + groovy + + + + + continue + + false + 1 + + 1 + 1 + 1487436097000 + 1487436097000 + false + + + + + + + + + + + + + + GET + true + false + false + false + + + + + + + + Connection: close + + Assertion.response_headers + false + 16 + + + + + + Connection: close + + Assertion.request_headers + false + 16 + + + + + + + + + + + + + + + + GET + true + false + true + false + + + + + + + + Keep-Alive: timeout=30, max=100 + + Assertion.response_headers + false + 16 + + + + + true + keepAliveTimeout + Keep-Alive: timeout=(.+?), max= + $1$ + nv_keepAliveTimeout + 1 + + + + true + + + int validationAfterInactivity = vars["validationAfterInactivity"].toInteger(); +int ttl = vars["ttl"].toInteger(); +int firstPause = validationAfterInactivity - 300; +int secondPause = validationAfterInactivity + 500; +int thirdPause = ttl-(firstPause+secondPause); + +vars.put("firstPause",firstPause.toString()); +vars.put("secondPause",secondPause.toString()); +vars.put("thirdPause",thirdPause.toString()); + groovy + + + + + true + 1 + + + + + + + + + + + + GET + true + false + true + false + + + + + + + + Keep-Alive: timeout=30, max=99 + + Assertion.response_headers + false + 16 + + + + + + 1 + 0 + 0 + + + + ${firstPause} + + + + + + + + + + + + + GET + true + false + true + false + + + + + + + + Keep-Alive: timeout=30, max=98 + + Assertion.response_headers + false + 16 + + + + + + 1 + 0 + 0 + + + + ${secondPause} + + + + + + + + + + + + + GET + true + false + true + false + + + + + + + + Keep-Alive: timeout=30, max=97 + + Assertion.response_headers + false + 16 + + + + + + 1 + 0 + 0 + + + + ${thirdPause} + + + + + + + + + + + + + GET + true + false + true + false + + + + + + + + Keep-Alive: timeout=30, max=100 + + Connection has exceeded its TTL + Assertion.response_headers + false + 16 + + + + + + 1 + 0 + 0 + + + + ${keepAliveTimeout}000 + + + + + + + + + + + + + GET + true + false + true + false + + + + + + + + Keep-Alive: timeout=30, max=100 + + Connection has exceeded its TTL + Assertion.response_headers + false + 16 + + + + + + + + Connection: keep-alive + + Assertion.request_headers + false + 16 + + + + + + + false + + saveConfig + + + false + false + true + + true + true + true + true + false + false + false + false + false + false + false + false + false + false + false + 0 + + + TestKeepAlive.csv + + + + false + + saveConfig + + + false + false + true + + true + true + false + true + false + true + true + false + false + true + false + false + false + false + false + 0 + + + TestKeepAlive.xml + + + + + diff --git a/src/test/resources/jmeter-regression/5.6.3/TestRedirectionPolicies.jmx b/src/test/resources/jmeter-regression/5.6.3/TestRedirectionPolicies.jmx new file mode 100644 index 0000000..0f24954 --- /dev/null +++ b/src/test/resources/jmeter-regression/5.6.3/TestRedirectionPolicies.jmx @@ -0,0 +1,465 @@ + + + + + + false + true + true + + + + + + + + + + + httpbin.org + + + + /redirect/1 + 6 + + + + + + continue + + false + 1 + + 1 + 1 + false + + + + + + + + + + + + + + GET + false + true + true + false + + + + + + + + 200 + + + Assertion.response_code + false + 16 + + + + true + + + assert prev.getSubResults().length==0 + groovy + + + + url + $..url + 1 + nv_url + + + + + https://httpbin.org/get + + + Assertion.response_data + false + 16 + variable + url + + + + + + + + + + + + + GET + false + false + true + false + + + + + + + + 302 + + + Assertion.response_code + false + 16 + + + + true + + + assert prev.getSubResults().length==0 + groovy + + + + + + + + + + + + + GET + true + false + true + false + + + + + + + + 200 + + + Assertion.response_code + false + 16 + + + + true + + + assert prev.getSubResults().length==2 + groovy + + + + url + $..url + 1 + nv_url + + + + + https://httpbin.org/get + + + Assertion.response_data + false + 16 + variable + url + + + + + + continue + + false + 1 + + 1 + 1 + false + + + + + + + + + + + + + + GET + false + false + true + false + + + + + + + + 302 + + + Assertion.response_code + false + 16 + + + + true + + + assert prev.getSubResults().length==0 + groovy + + + + + + + + + + + + + GET + true + false + true + false + + + + + + + + 200 + + + Assertion.response_code + false + 16 + + + + true + + + assert prev.getSubResults().length==2 + groovy + + + + url + $..url + 1 + nv_url + + + + + https://httpbin.org/get + + + Assertion.response_data + false + 16 + variable + url + + + + + + + + + + + + + GET + false + true + true + false + + + + + + + + 200 + + + Assertion.response_code + false + 16 + + + + true + + + assert prev.getSubResults().length==0 + groovy + + + + url + $..url + 1 + nv_url + + + + + https://httpbin.org/get + + + Assertion.response_data + false + 16 + variable + url + + + + + + false + + saveConfig + + + false + false + true + + true + true + true + true + false + false + false + false + false + false + false + false + false + false + false + 0 + + + TestRedirectionPolicies.csv + + + + false + + saveConfig + + + false + false + true + + true + true + false + true + false + true + true + false + false + true + false + false + false + false + false + 0 + + + TestRedirectionPolicies.xml + + + + true + + saveConfig + + + true + true + true + + true + true + true + true + true + true + true + true + true + true + false + true + true + false + true + 0 + true + true + true + true + true + true + true + true + true + + + TestRedirectionPolicies-errors.xml + + + + + diff --git a/src/test/resources/jmeter-regression/5.6.3/jmeter-batch.properties b/src/test/resources/jmeter-regression/5.6.3/jmeter-batch.properties new file mode 100644 index 0000000..a93d6f1 --- /dev/null +++ b/src/test/resources/jmeter-regression/5.6.3/jmeter-batch.properties @@ -0,0 +1,40 @@ +################################################################################ +# Apache JMeter Property file for batch runs +################################################################################ + +## 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. + +# Revert to original default mode +mode=Standard + +# Since JMeter 2.12, defaults for this property is true +jmeter.save.saveservice.thread_counts=false +# Since JMeter 3.0, defaults for this property is true +jmeter.save.saveservice.idle_time=false +# Since JMeter 3.1, defaults for this property is true +jmeter.save.saveservice.connect_time=false +# Since JMeter 3.1, defaults for this property is true +jmeter.save.saveservice.sent_bytes=false +# Since JMeter 5.0, defaults for this property is true +jmeter.save.saveservice.url=false + +# add some context in case tests fail (.jtl files are not compared) +jmeter.save.saveservice.responseHeaders=true +jmeter.save.saveservice.output_format=xml + +HTTPResponse.parsers=htmlParser +htmlParser.className=org.apache.jmeter.protocol.http.parser.LagartoBasedHtmlParser +htmlParser.types=text/html application/xhtml+xml application/xml text/xml diff --git a/src/test/resources/jmeter-regression/5.6.3/log4j2-batch.xml b/src/test/resources/jmeter-regression/5.6.3/log4j2-batch.xml new file mode 100644 index 0000000..65595d2 --- /dev/null +++ b/src/test/resources/jmeter-regression/5.6.3/log4j2-batch.xml @@ -0,0 +1,49 @@ + + + + + + + + + %d %p %c{1.}: %m%n + + + + + + %d %p %c{1.}: %m%n + + + + + + + + + + + + + + + + + + + diff --git a/src/test/resources/jmeter-regression/5.6.3/testfiles/HTMLParserTestFile_2.html b/src/test/resources/jmeter-regression/5.6.3/testfiles/HTMLParserTestFile_2.html new file mode 100644 index 0000000..b28f249 --- /dev/null +++ b/src/test/resources/jmeter-regression/5.6.3/testfiles/HTMLParserTestFile_2.html @@ -0,0 +1,1277 @@ + + + + + + + + + + + + + +JMeter - User's Manual: Elements of a Test Plan + + + + + + + + + + +
+ + + +Jakarta + +JMeter +
+ + + + + + + + + + +
+
+
+

About

+ +

Download

+ +

Documentation

+ +

Tutorials (PDF format)

+ +

Community

+ +

Foundation

+ +
+ + + + + + +
+ + + + + +
+
+ + + + +
+ +4. Elements of a Test Plan +
+
+

+The Test Plan object has a checkbox called "Functional Testing". If selected, it +will cause JMeter to record the data returned from the server for each sample. If you have +selected a file in your test listeners, this data will be written to file. This can be useful if +you are doing a small run to ensure that JMeter is configured correctly, and that your server +is returning the expected results. The consequence is that the file will grow huge quickly, and +JMeter's performance will suffer. This option should be off if you are doing stress-testing (it +is off by default). +

+

+If you are not recording the data to file, this option makes no difference. +

+

+You can also use the Configuration button on a listener to decide what fields to save. +

+ + + + +
+ +4.1 ThreadGroup + +
+
+

+Thread group elements are the beginning points of any test plan. +All controllers and samplers must be under a thread group. +Other elements, e.g. Listeners, may be placed directly under the test plan, +in which case they will apply to all the thread groups. +As the name implies, the thread group +element controls the number of threads JMeter will use to execute your test. The +controls for a thread group allow you to: + +

    +
  • +Set the number of threads +
  • + + +
  • +Set the ramp-up period +
  • + + +
  • +Set the number of times to execute the test +
  • + + +
+

+

+Each thread will execute the test plan in its entirety and completely independently +of other test threads. Multiple threads are used to simulate concurrent connections +to your server application. +

+

+The ramp-up period tells JMeter how long to take to "ramp-up" to the full number of +threads chosen. If 10 threads are used, and the ramp-up period is 100 seconds, then +JMeter will take 100 seconds to get all 10 threads up and running. Each thread will +start 10 (100/10) seconds after the previous thread was begun. If there are 30 threads +and a ramp-up period of 120 seconds, then each successive thread will be delayed by 4 seconds. +

+

+Ramp-up needs to be long enough to avoid too large a work-load at the start +of a test, and short enough that the last threads start running before +the first ones finish (unless one wants that to happen). + +

+

+ +Start with Ramp-up = number of threads and adjust up or down as needed. + +

+

+By default, the thread group is configured to loop once through its elements. +

+

+Version 1.9 introduces a test run + +scheduler + +. + Click the checkbox at the bottom of the Thread Group panel to reveal extra fields + in which you can enter the start and end times of the run. + When the test is started, JMeter will wait if necessary until the start-time has been reached. + At the end of each cycle, JMeter checks if the end-time has been reached, and if so, the run is stopped, + otherwise the test is allowed to continue until the iteration limit is reached. +

+

+Alternatively, one can use the relative delay and duration fields. + Note that delay overrides start-time, and duration over-rides end-time. +

+
+

+ + + + +
+ +4.2 Controllers + +
+
+

+ +JMeter has two types of Controllers: Samplers and Logical Controllers. +These drive the processing of a test. + +

+

+Samplers tell JMeter to send requests to a server. For +example, add an HTTP Request Sampler if you want JMeter +to send an HTTP request. You can also customize a request by adding one +or more Configuration Elements to a Sampler. For more +information, see + + +Samplers + +. +

+

+Logical Controllers let you customize the logic that JMeter uses to +decide when to send requests. For example, you can add an Interleave +Logic Controller to alternate between two HTTP Request Samplers. +For more information, see + +Logical Controllers + +. +

+
+

+ + + + +
+ +4.2.1 Samplers + +
+
+

+ +Samplers tell JMeter to send requests to a server and wait for a response. +They are processed in the order they appear in the tree. +Controllers can be used to modify the number of repetitions of a sampler. + +

+

+ +JMeter samplers include: + +

    + + +
  • +FTP Request +
  • + + +
  • +HTTP Request +
  • + + +
  • +JDBC Request +
  • + + +
  • +Java object request +
  • + + +
  • +LDAP Request +
  • + + +
  • +SOAP/XML-RPC Request +
  • + + +
  • +WebService (SOAP) Request +
  • + + +
+ +Each sampler has several properties you can set. +You can further customize a sampler by adding one or more Configuration Elements to the Test Plan. + +

+

+If you are going to send multiple requests of the same type (for example, +HTTP Request) to the same server, consider using a Defaults Configuration +Element. Each controller has one or more Defaults elements (see below). +

+

+Remember to add a Listener to your test plan to view and/or store the +results of your requests to disk. +

+

+If you are interested in having JMeter perform basic validation on +the response of your request, add an + +Assertion + + to +the sampler. For example, in stress testing a web application, the server +may return a successful "HTTP Response" code, but the page may have errors on it or +may be missing sections. You could add assertions to check for certain HTML tags, +common error strings, and so on. JMeter lets you create these assertions using regular +expressions. +

+

+ +JMeter's built-in samplers + +

+
+

+ + + + +
+ +4.2.2 Logic Controllers + +
+
+

+Logic Controllers let you customize the logic that JMeter uses to +decide when to send requests. +Logic Controllers can change the order of requests coming from their +child elements. They can modify the requests themselves, cause JMeter to repeat +requests, etc. + +

+

+To understand the effect of Logic Controllers on a test plan, consider the +following test tree: +

+

+ + +

    + + +
  • +Test Plan +
  • + + +
      + + +
    • +Thread Group +
    • + + +
        + + +
      • +Once Only Controller +
      • + + + + + +
      • +Load Search Page (HTTP Sampler) +
      • + + +
      • +Interleave Controller +
      • + + +
          + + +
        • +Search "A" (HTTP Sampler) +
        • + + +
        • +Search "B" (HTTP Sampler) +
        • + + +
        • +HTTP default request (Configuration Element) +
        • + + +
        + + +
      • +HTTP default request (Configuration Element) +
      • + + +
      • +Cookie Manager (Configuration Element) +
      • + + +
      + + +
    + + +
+ + +

+

+The first thing about this test is that the login request will be executed only +the first time through. Subsequent iterations will skip it. This is due to the +effects of the +Once Only Controller +. +

+

+After the login, the next Sampler loads the search page (imagine a +web application where the user logs in, and then goes to a search page to do a search). This +is just a simple request, not filtered through any Logic Controller. +

+

+After loading the search page, we want to do a search. Actually, we want to do +two different searches. However, we want to re-load the search page itself between +each search. We could do this by having 4 simple HTTP request elements (load search, +search "A", load search, search "B"). Instead, we use the +Interleave Controller + which passes on one child request each time through the test. It keeps the +ordering (ie - it doesn't pass one on at random, but "remembers" its place) of its +child elements. Interleaving 2 child requests may be overkill, but there could easily have +been 8, or 20 child requests. +

+

+Note the +HTTP Request Defaults + that +belongs to the Interleave Controller. Imagine that "Search A" and "Search B" share +the same PATH info (an HTTP request specification includes domain, port, method, protocol, +path, and arguments, plus other optional items). This makes sense - both are search requests, + hitting the same back-end search engine (a servlet or cgi-script, let's say). Rather than + configure both HTTP Samplers with the same information in their PATH field, we + can abstract that information out to a single Configuration Element. When the Interleave + Controller "passes on" requests from "Search A" or "Search B", it will fill in the blanks with + values from the HTTP default request Configuration Element. So, we leave the PATH field + blank for those requests, and put that information into the Configuration Element. In this +case, this is a minor benefit at best, but it demonstrates the feature. +

+

+The next element in the tree is another HTTP default request, this time added to the +Thread Group itself. The Thread Group has a built-in Logic Controller, and thus, it uses +this Configuration Element exactly as described above. It fills in the blanks of any +Request that passes through. It is extremely useful in web testing to leave the DOMAIN +field blank in all your HTTP Sampler elements, and instead, put that information +into an HTTP default request element, added to the Thread Group. By doing so, you can +test your application on a different server simply by changing one field in your Test Plan. +Otherwise, you'd have to edit each and every Sampler. +

+

+The last element is a +HTTP Cookie Manager +. A Cookie Manager should be added to all web tests - otherwise JMeter will +ignore cookies. By adding it at the Thread Group level, we ensure that all HTTP requests +will share the same cookies. +

+

+Logic Controllers can be combined to achieve various results. See the list of + +built-in +Logic Controllers + +. +

+
+

+ + + + +
+ +4.2.3 Test Fragments + +
+
+

+The Test Fragment element is a special type of + +controller + + that +exists on the Test Plan tree at the same level as the Thread Group element. It is distinguished +from a Thread Group in that it is not executed unless it is +referenced by either a +Module Controller + or an +Include_Controller +. + +

+

+This element is purely for code re-use within Test Plans and was introduced in Version 2.5 +

+
+

+ + + + +
+ +4.3 Listeners + +
+
+

+Listeners provide access to the information JMeter gathers about the test cases while +JMeter runs. The +Graph Results + listener plots the response times on a graph. +The "View Results Tree" Listener shows details of sampler requests and +responses, and can display basic HTML and XML representations of the +response. +Other listeners provide summary or aggregation information. + +

+

+ +Additionally, listeners can direct the data to a file for later use. +Every listener in JMeter provides a field to indicate the file to store data to. +There is also a Configuration button which can be used to choose which fields to save, and whether to use CSV or XML format. + + +Note that all Listeners save the same data; the only difference is in the way the data is presented on the screen. + + + +

+

+ +Listeners can be added anywhere in the test, including directly under the test plan. +They will collect data only from elements at or below their level. + +

+

+There are several + +listeners + + +that come with JMeter. +

+
+

+ + + + +
+ +4.4 Timers + +
+
+

+By default, a JMeter thread sends requests without pausing between each request. +We recommend that you specify a delay by adding one of the available timers to +your Thread Group. If you do not add a delay, JMeter could overwhelm your server by +making too many requests in a very short amount of time. +

+

+The timer will cause JMeter to delay a certain amount of time + +before + + each +sampler which is in its + +scope + +. +

+

+ +If you choose to add more than one timer to a Thread Group, JMeter takes the sum of +the timers and pauses for that amount of time before executing the samplers to which the timers apply. +Timers can be added as children of samplers or controllers in order to restrict the samplers to which they are applied. + +

+

+ +To provide a pause at a single place in a test plan, one can use the +Test Action + Sampler. + +

+
+

+ + + + +
+ +4.5 Assertions + +
+
+

+Assertions allow you to assert facts about responses received from the +server being tested. Using an assertion, you can essentially "test" that your +application is returning the results you expect it to. +

+

+For instance, you can assert that the response to a query will contain some +particular text. The text you specify can be a Perl-style regular expression, and +you can indicate that the response is to contain the text, or that it should match +the whole response. +

+

+You can add an assertion to any Sampler. For example, you can +add an assertion to a HTTP Request that checks for the text, "</HTML>". JMeter +will then check that the text is present in the HTTP response. If JMeter cannot find the +text, then it will mark this as a failed request. +

+

+ +Note that assertions apply to all samplers which are in its + +scope + +. +To restrict the assertion to a single sampler, add the assertion as a child of the sampler. + +

+

+To view the assertion results, add an Assertion Listener to the Thread Group. +Failed Assertions will also show up in the Tree View and Table Listeners, +and will count towards the error %age for example in the Aggregate and Summary reports. + +

+
+

+ + + + +
+ +4.6 Configuration Elements + +
+
+

+A configuration element works closely with a Sampler. Although it does not send requests +(except for +HTTP Proxy Server +), it can add to or modify requests. +

+

+A configuration element is accessible from only inside the tree branch where you place the element. +For example, if you place an HTTP Cookie Manager inside a Simple Logic Controller, the Cookie Manager will +only be accessible to HTTP Request Controllers you place inside the Simple Logic Controller (see figure 1). +The Cookie Manager is accessible to the HTTP requests "Web Page 1" and "Web Page 2", but not "Web Page 3". +

+

+Also, a configuration element inside a tree branch has higher precedence than the same element in a "parent" +branch. For example, we defined two HTTP Request Defaults elements, "Web Defaults 1" and "Web Defaults 2". +Since we placed "Web Defaults 1" inside a Loop Controller, only "Web Page 2" can access it. The other HTTP +requests will use "Web Defaults 2", since we placed it in the Thread Group (the "parent" of all other branches). +

+


+Figure 1 - + Test Plan Showing Accessibility of Configuration Elements +

+

+

+ +
+The +User Defined Variables + Configuration element is different. +It is processed at the start of a test, no matter where it is placed. +For simplicity, it is suggested that the element is placed only at the start of a Thread Group. + +
+

+
+

+ + + + +
+ +4.7 Pre-Processor Elements + +
+
+

+A Pre-Processor executes some action prior to a Sampler Request being +made. +If a Pre-Processor is attached to a Sampler element, then it will +execute just prior to that sampler element running. +A Pre-Processor is most often used to modify the settings of a Sample +Request just before it runs, or to update variables that aren't +extracted from response text. +See the + + +scoping rules + + + for more details on when Pre-Processors are executed. +

+
+

+ + + + +
+ +4.8 Post-Processor Elements + +
+
+

+A Post-Processor executes some action after a Sampler Request has been made. +If a Post-Processor is attached to a Sampler element, then it will execute just after that sampler element runs. +A Post-Processor is most often used to process the response data, often to extract values from it. +See the + + +scoping rules + + + for more details on when Post-Processors are executed. +

+
+

+ + + + +
+ +4.9 Execution order + +
+
+
    + + +
  1. +Configuration elements +
  2. + + +
  3. +Pre-Processors +
  4. + + +
  5. +Timers +
  6. + + +
  7. +Sampler +
  8. + + +
  9. +Post-Processors (unless SampleResult is null) +
  10. + + +
  11. +Assertions (unless SampleResult is null) +
  12. + + +
  13. +Listeners (unless SampleResult is null) +
  14. + + +
+

+

+ +
+Please note that Timers, Assertions, Pre- and Post-Processors are only processed if there is a sampler to which they apply. +Logic Controllers and Samplers are processed in the order in which they appear in the tree. +Other test elements are processed according to the scope in which they are found, and the type of test element. +[Within a type, elements are processed in the order in which they appear in the tree]. + +
+

+

+ +For example, in the following test plan: + +

    + + +
  • +Controller +
  • + + +
      + + +
    • +Post-Processor 1 +
    • + + +
    • +Sampler 1 +
    • + + +
    • +Sampler 2 +
    • + + +
    • +Timer 1 +
    • + + +
    • +Assertion 1 +
    • + + +
    • +Pre-Processor 1 +
    • + + +
    • +Timer 2 +
    • + + +
    • +Post-Processor 2 +
    • + + +
    + + +
+ +The order of execution would be: + +
+Pre-Processor 1
+Timer 1
+Timer 2
+Sampler 1
+Post-Processor 1
+Post-Processor 2
+Assertion 1
+
+Pre-Processor 1
+Timer 1
+Timer 2
+Sampler 2
+Post-Processor 1
+Post-Processor 2
+Assertion 1
+
+
+ + +

+
+

+ + + + +
+ +4.10 Scoping Rules + +
+
+

+ +The JMeter test tree contains elements that are both hierarchical and +ordered. Some elements in the test trees are strictly hierarchical +(Listeners, Config Elements, Post-Procesors, Pre-Processors, Assertions, + Timers), and some are primarily ordered (controllers, samplers). When +you create your test plan, you will create an ordered list of sample +request (via Samplers) that represent a set of steps to be executed. +These requests are often organized within controllers that are also +ordered. Given the following test tree: +

+


+Example test tree +

+

+The order of requests will be, One, Two, Three, Four. +

+

+Some controllers affect the order of their subelements, and you can read about these specific controllers in + +the component reference + +. +

+

+Other elements are hierarchical. An Assertion, for instance, is hierarchical in the test tree. +If its parent is a request, then it is applied to that request. If its +parent is a Controller, then it affects all requests that are descendants of +that Controller. In the following test tree: +

+


+Hierarchy example +

+

+Assertion #1 is applied only to Request One, while Assertion #2 is applied to Requests Two and Three. +

+

+Another example, this time using Timers: +

+


+complex example +

+

+In this example, the requests are named to reflect the order in which +they will be executed. Timer #1 will apply to Requests Two, Three, and +Four (notice how order is irrelevant for hierarchical elements). +Assertion #1 will apply only to Request Three. Timer #2 will affect all + the requests. +

+

+Hopefully these examples make it clear how configuration (hierarchical) +elements are applied. If you imagine each Request being passed up the +tree branches, to its parent, then to its parent's parent, etc, and each + time collecting all the configuration elements of that parent, then you + will see how it works. +

+ + +The Configuration elements Header Manager, Cookie Manager and Authorization manager are +treated differently from the Configuration Default elements. +The settings from the Configuration Default elements are merged into a set of values that the Sampler has access to. +However, the settings from the Managers are not merged. +If more than one Manager is in the scope of a Sampler, +only one Manager is used, but there is currently no way to specify + +which + + is used. + + +
+

+ + + + +
+ +4.11 Properties and Variables + +
+
+

+ +JMeter + +properties + + are defined in jmeter.properties (see + +Gettting Started - Configuring JMeter + + for more details). + +
+
+ +Properties are global to jmeter, and are mostly used to define some of the defaults JMeter uses. +For example the property remote_hosts defines the servers that JMeter will try to run remotely. +Properties can be referenced in test plans +- see + +Functions - read a property + + - +but cannot be used for thread-specific values. + +

+

+ +JMeter + +variables + + are local to each thread. The values may be the same for each thread, or they may be different. + +
+
+ +If a variable is updated by a thread, only the thread copy of the variable is changed. +For example the +Regular Expression Extractor + Post-Processor +will set its variables according to the sample that its thread has read, and these can be used later +by the same thread. +For details of how to reference variables and functions, see + +Functions and Variables + + + +

+

+ +Note that the values defined by the +Test Plan + and the +User Defined Variables + configuration element +are made available to the whole test plan at startup. +If the same variable is defined by multiple UDV elements, then the last one takes effect. +Once a thread has started, the initial set of variables is copied to each thread. +Other elements such as the + +User Parameters + Pre-Processor or +Regular Expression Extractor + Post-Processor +may be used to redefine the same variables (or create new ones). These redefinitions only apply to the current thread. + +

+

+ +The + +setProperty + + function can be used to define a JMeter property. +These are global to the test plan, so can be used to pass information between threads - should that be needed. + +

+

+

+ +
Both variables and properties are case-sensitive. +
+

+
+

+ + + + +
+ +4.12 Using Variables to parameterise tests + +
+
+

+ +Variables don't have to vary - they can be defined once, and if left alone, will not change value. +So you can use them as short-hand for expressions that appear frequently in a test plan. +Or for items which are constant during a run, but which may vary between runs. +For example, the name of a host, or the number of threads in a thread group. + +

+

+ +When deciding how to structure a Test Plan, +make a note of which items are constant for the run, but which may change between runs. +Decide on some variable names for these - +perhaps use a naming convention such as prefixing them with C_ or K_ or using uppercase only +to distinguish them from variables that need to change during the test. +Also consider which items need to be local to a thread - +for example counters or values extracted with the Regular Expression Post-Processor. +You may wish to use a different naming convention for these. + +

+

+ +For example, you might define the following on the Test Plan: + +

+HOST             www.example.com
+THREADS          10
+LOOPS            20
+
+
+ +You can refer to these in the test plan as ${HOST} ${THREADS} etc. +If you later want to change the host, just change the value of the HOST variable. +This works fine for small numbers of tests, but becomes tedious when testing lots of different combinations. +One solution is to use a property to define the value of the variables, for example: + +
+HOST             ${__P(host,www.example.com)}
+THREADS          ${__P(threads,10)}
+LOOPS            ${__P(loops,20)}
+
+
+ +You can then change some or all of the values on the command-line as follows: + +
+jmeter ... -Jhost=www3.example.org -Jloops=13
+
+
+ + +

+
+

+
+

+

+ + + + + + +
+ + + + + +
+
+
+
+
+
+Copyright © 1999-2011, Apache Software Foundation +
+
+
+Apache, Apache JMeter, JMeter, the Apache feather, and the Apache JMeter logo are +trademarks of the Apache Software Foundation. + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/resources/jmeter-regression/5.6.3/testfiles/HTMLParserTestFile_2_files/halfbanner.htm b/src/test/resources/jmeter-regression/5.6.3/testfiles/HTMLParserTestFile_2_files/halfbanner.htm new file mode 100644 index 0000000..55dbc5c --- /dev/null +++ b/src/test/resources/jmeter-regression/5.6.3/testfiles/HTMLParserTestFile_2_files/halfbanner.htm @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/test/resources/jmeter-regression/5.6.3/testfiles/HTMLParserTestFile_2_files/halfbanner_data/2011-na-234x60.png b/src/test/resources/jmeter-regression/5.6.3/testfiles/HTMLParserTestFile_2_files/halfbanner_data/2011-na-234x60.png new file mode 100644 index 0000000000000000000000000000000000000000..7dc685c332e689941e180df6d6463b29f5129160 GIT binary patch literal 5421 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 Zi_RYUd+g+2--d3(NZ%FJU@*Yq{{T+YGx7ic literal 0 HcmV?d00001 diff --git a/src/test/resources/jmeter-regression/5.6.3/testfiles/HTMLParserTestFile_2_files/logo.jpg b/src/test/resources/jmeter-regression/5.6.3/testfiles/HTMLParserTestFile_2_files/logo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..304363abfc4c9a325495e0f138255dc6d9d90cc8 GIT binary patch literal 8886 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 ZV*c-60iKKhjWy+wHHH4iKLk$;{|kkP2}=L~ literal 0 HcmV?d00001 diff --git a/src/test/resources/jmeter-regression/5.6.3/testfiles/HTMLParserTestFile_2_files/scoping1.png b/src/test/resources/jmeter-regression/5.6.3/testfiles/HTMLParserTestFile_2_files/scoping1.png new file mode 100644 index 0000000000000000000000000000000000000000..bfbc5eb7a87d9fcf40f40eecf9da1b29141d7423 GIT binary patch literal 2395 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 N002ovPDHLkV1i<}D;59% literal 0 HcmV?d00001 diff --git a/src/test/resources/jmeter-regression/5.6.3/testfiles/HTMLParserTestFile_2_files/scoping2.png b/src/test/resources/jmeter-regression/5.6.3/testfiles/HTMLParserTestFile_2_files/scoping2.png new file mode 100644 index 0000000000000000000000000000000000000000..c18ca840fc8fb0064eba6b88d988352ecc56852a GIT binary patch literal 2641 zcmV-X3a<5uP)Px%yHHG2MgRZ*nVHPj&gGhuq}|%%i-MS;m9)&v{{R30 zglM{EW|??vjH{o#{{H^q-RQ`^*pzm`u&BFXVPS7(eOg&toruJFa*Fcu^Mq-+gJ-yw zjHK4p*qn#Ng>ACRzuvU0!PU&<=;iE)ftJq2-G+Id*3IOReYm%<#*~7$&dSx@+Tw_C zx4*f{lzqYb`}>)Rw}ff9zOmMjd%D51+@g=gi*dZ&+2w|9xPfT3#Jbh-@$!Llney`T z^z-%n{QULx^@VG<_V)Jr`ugYQ=gGp_)zjFyu*ZpUwyvnRtgNkkWUAuf<(8A3iE*@m zX0e!xvdhTO($LhRo2$#o&Cku!(8lB3+uxpv#@W}~&CJl>-Qu^izmJHR%gE82m!i|q z+1}jX(azY0fREkV;I*#3&C1iEou|pg&xU}Gwz9w0)7+n%rN_g~*3#VE+TYaB+JJnC z+u7c`w!)yDriX!$uBogfRD+)-j;yBnS{cb zgTlqW(4dsGzO>JRbeY!8<=@)r&&J+>bC{lzvdqHUgLa#NbeV~KpvJt`f^?e5zSm@7 zXVJ&s+ScLm@bul+=i%Dy z+!y`&d$W$g?F5^sm6+Zp~Jn-zqrcd;OMNNzJGC-rkc6V z$=KJ^;J>xdx30$2%i^1lu)4FyqnEdkgr~;9(X64rshzyh%H5=xxS*A?l!&UPo4VT6 z;D2(LyRgixqrA+++=6wQ%E8)za+s)`yMTI-nUklQjjxP_o4~fvhkuyn6&iWWQ<}#lf~K#P*Ug^OiAm%b1y&nRyEpR_>UYnVFfH z8UKt)79B{A>`JG*R__mXtSFy6dhfl{ef7R~MyQslD)ymJpP`hEV3!PDl^9-aSJn1* zReX3bjbX34-lrE89>$r*(px6H=V4q^q0rPs)8|xmU;vAGt9gI`v_IL0AvJgtpuobL zag!rDEDZ?WE2ImLXn1wNgdb2Dc-#HftJ4r3ywd{qQJ!IgOnA~`Io0a1IU3&gGfa5y7ZHC}s!%D*r$MJ1~v9s_2(y&8YuB~nS`0=f+Mysavc+CR{!tdN(EAoYB zq#*{c_4GLORlKp_YoC)4Jfh$=2i^kj(QtUnv(*-`eeAy4$lzUxdxG;vD4qr>M39E2 z=go+(!doA4&x3yf4x>v-z|$u{JPlHaAPxPn(Wt<{i0m=S>j~grAJC`97EKX`N?;oFwa8*;%T!|(74339@3w(lyb<7b5ZMR_yrCXgzWAe=qIc(Uh;pu0cdfbU8?7c|~ExJ8jn{+co z?63h@!waUKaq=lgPu`k+V&DqZ#9iO;PLLfkz`6t!RuZ7opZpZlFsKR zZNKMUn@cK2CT^6IS4rh`>1ALcgBJrHJd+XLEhdr^-hBgU;mvo6;rY=g0gulmYFtKe zhM}J6Xd>`FeS}nP5Im#_U!dU0JSVt93(Ii2c_8O72Y9kTC>)+mra4(DHn&M-aFc^a zV|v~r2QQca&vrsUK2aQa?|utp9}RNJ?Ww=^4`0JnwUoMvcDuTn) zqu&e@{MILb``0>N5!~}kDsy>7xpDTR3;IX?t5izE--ry)q%voi@VDGNXV!ajd!eGV z4ifDHwG9Z@**K+>uX4{!)~y?!h>qrrcwvyL{mBROU>xyFNJY z;r^%kKbmL41Hs|QIJ{nzoYmvn&r^Ls*2YGK zVL45z(2}j>#p-miU&hs8sZ{2jCj8J)@Atjk-Q6!NeB?gju>t0Hm&hHgNOoVOc5Ym#fnqHaU1S_}a&PeWOa> zS8onIUgtA0{&O3q_doOc`#-~XpT7O#AQB#)J+J)g2Vea7)=N)hVkCpF>%7#fToZxk zRppulJhv*>1mO8qxhCOxZdI-c!1JqejUAq8F?r7UT6iTjfef6hH#Q2UNLA10PuBO07v26+W!Wm3-PHOot^u@~?7@ z5uSgQYmD&xt6bxQPoc^+KKK->Tr(M%$-qnoW-@STl7S!xe6S3Z1U^g#2JV9+i3|+f z2S*YaNbiFa(*qGcGB9W#9BE`=z&<#Fg-s0^7_bixh~@cA24*ralYw%|zzm)%e73ML zJ$UymTiBQm{Ib5VF=D3|a$#fC8MBZJ8xarqs7%h=(0R0-^n2WJZs^5v~t_(v|;!fd$bA!r|zsj{ot5EhoFl;+)R|EWk00000NkvXXu0mjfjmVfK literal 0 HcmV?d00001 diff --git a/src/test/resources/jmeter-regression/5.6.3/testfiles/HTMLParserTestFile_2_files/scoping3.png b/src/test/resources/jmeter-regression/5.6.3/testfiles/HTMLParserTestFile_2_files/scoping3.png new file mode 100644 index 0000000000000000000000000000000000000000..d263fa2921573ea5fece82dc5c5c7887d76b4cf8 GIT binary patch literal 3260 zcmZ{nXE+;**T6$mX`{4M6IJC_i)z)ptzK%xs!^n<%imVxYPI$r5e;G&kytTnD?vgv zVhe%@V$`fnam}{ooA-JDU*GdQ=Xb_)&WH1y^X0@Cz0l^mByb4;0B}9m(SR{+J5!f9 zfJ{!9)YdW$o3omM8URp}c=^Qc0yAduf@!M*ss;qt0036Jk-mxM`T02(OU+BGb8^Ct z4J=JhF2qJ)sMK?2aiq(ajFduux43fL*Xoj{v$L}ZZ)|pS2c>y@etI>)6Scd$XJ==Z z>f1UpG>HsPWb`jZx;9RKo9iXdymJb%_}elXjvnZoZmt_VJUkqwE+PYRR+oM-hNtQ( zT2D?+mcFkK^o~q2=EFTPl;*LDib`}`A;P(VF*;7HCFf-q!-G-e#@-Ka@z}_=e(GRv z*Wl{nR%&!v$Cr_n<<+VZVpA<;VSc%{ZE|94YItyBW_oUFa<;ppf9~5_Ps>D9RCEd| z@9i7+4$>g*V{Kt!QC4DgLse%nu0GN?v$L6&{Go)}KDD^GSee^0K0Mn-9L`OxE5tM; zhcpnYyQ1F{QzMAeliyQATCp+2<{Cy)(3jTQsaTJ%`H7v=6HCaq4RDwGot@neZp7o` zYI$Ve^5CX|-tkYa*!6X1m< z*^Qqvza${Do2vU;2((e^LN|%gSlvC;JxlMKt}GxG=haLOE#Yvup#g@zzCId_wz9Gg z3kzFYThGH*hWJG-&u@kMC7`0?aF{axcOTxngm}7!k%--GP5ql2JE)Mny3#JDkjdnp zcDk3B*XHJ?+iNdZS63MsnGnxd*bDQbj5>8S_3*%?g7o^TBGT;my0JM+8UVmq@?1mB z#D8)#`)Am&7P#wC7k=73R3aT*y*4lI_YOl9Nywk=}f;xqmu#$23e9yT?BUTO@3ir6bDCSfHICi z7LA|M(%pKcRY^u1PQw4;=z-W?B++=ZjPG8lhN{V17^$jvC{&hmu5oKs3!4q@H|^Vr zXQ+MFPIaQ)d?~mw?5Lc#cd5u0Ac|lV%E`O(L7#%V%CGZBwW73-O2P?j-s~sGv2I$$ z2`Qj0;+2>u=9cAF9^1ngE|~by9b{*51&CgG`^%oj_Pw!5$ zmY6w@>iIF5_oWEPBormBA#n2{M=koHjN>ygb9Pl}jR))nXYp54TYu&B`xlZujuouD zRsj=Y=3#6Yq*#V#Xj!+~HrGAG4u3q*Td|^S@_HAT!Ye%duSPA!aGJm;VsRN(vp-5* zyG!egTr91~H0K{PBRull?t#Q!85gf}# z|CZ_4Q{5~cZ`>!yM_)@iWPEDTwq?u=B?8eU7U^h^RU$v4r04GPp&G`DjC7JcB$J;- zf>7R^A3o~JhC9*?Zu3Eigu}0HpBr~~;n}H94QpBD4mIY6X%MZ1FGK$PJ~3?|zv%$W zifU=bZ~L@`_iie;MEIMG?2u9K9$Uax`lMj!yoTN6s4W_#s)6slzA{#p@71%wErf$s zN0kK!{<&e1qyI_8aA1zo-tq=2MsQL9?Ri<30os?FXK{|dML(q;yNln$0iP)TS)98l z6vcw*${9PYIlH{(4DqE?C3zI(loU()YOfnkO_z?Rs~w#g{q|13F%Vi4_W1szaA8a# zRvG%Wu2LFnXrTqJH4=fL)l<02mh&E7pyH@E_Mls7FEY%Ju4-pU$5Uwyrzy-Qoe zGWY)ZoyIP`5g+?w%ZqOFUqjkI4X~9-(^eFIDDA9Qk7Z~td*v!Yzd;;_bb)qr6>03P zEtZm8;qWFi0S!Taw;~ql_hY(PIUWlMZiQ$CU{M+hQ}9iT9VV%%%}|q&d}~ipm(*md zY8inMJaX${59A_LL)p12O%&J-D^oG>M~VoX8A?SkrIL9XA6$z-kjVey2e`wv=)ZWX zVBj%(!l<)UL%P2_`%mFJ-(Z5r!WDq;dCivC0oduCL|DiT%umKhY03%}_{|#kYvawq zB>hL_`3XtP``;{s5Z%AFev<+>2X&u~Va~=%rN<9C{{&=k%a;X&0p8_I0$TcbZ5V!G z6GYwTNY{7n44YS>4RrrnCjnJY<5`hwDUlH(?QFHD`T=2;HERmWSOY248IJ)gvQv`U zAGGo8*Z8>9-*HPG?z`b7Ilf66vbK$@m~<)vp)zS7=<*PPkQ;|dsG1tC7Q0?XwfD#a z$Df9t*oN%AxN!BNWj^#c>J6wshiTRSkc9H>OH2h0(nkh}gpYV9Nnv)+KHO#gEqeJeCLWn|)*AfjD-}jXSd$-#9-5 zOd&0Pyk^1E2bqJ%FId-Q!-cLx2X4Yz1FZj+&ae-%rEKKON-z2tSAm*!GoOAbDZcpG zcw|U3M9H7zV=OL-V8%qL`*Ogg){XMmz(Bzs8rp^1TE%K)ctI!P+2(xfMse&%A?-KL z+|ne%(R#kMkhbU4f(~1l)kyn-4vL^5<4Ds~R!YcC^)Q0nxC`F>Qo;ucurm2{B9h^p zl+5x}(~kYyij6CK5YW__MqHO;yVM%Xatq^_yK|;#b{S~p4wBN~|C3@3GGaxFONiPp zw~5c=h6~Qki5o z&e4Ap64v1_yL6g9DTat{?4AO%W(84WMJSEkSzy-eAgbgQwE^Yj4xZI12PqIsknE1F z@QM)h!onOWl&i+2Odm8+VFM)b8>9tP%#%a8$`5Q$>xsf6{Igw>we-au?+37 zYx^%VLqFiNOm?`BuX?>KIE=>eS>G8oy|A>!{cs7RI-~$Y_?^D){lCr=fl`=sU6cZ> z^Y@vo1JCMlUz}R*_M;|(=)lkPC39%%_9N!d&14Y7^YE0*o+s@Nw5pj52RV5;P$quf z;COAjbS#;~^62fqrx(5-0j)cJpWz)S(?1_ak4S09-+whbt19HXSoT-!?&)jh5`Lyj oAcSXa2q635Fo+z-qjTVLsHkja{dErJM*(=Q`9h;g-8SNX0PkLtXaE2J literal 0 HcmV?d00001 diff --git a/src/test/resources/jmeter-regression/5.6.3/testfiles/HTMLParserTestFile_2_files/style.css b/src/test/resources/jmeter-regression/5.6.3/testfiles/HTMLParserTestFile_2_files/style.css new file mode 100644 index 0000000..7d3a682 --- /dev/null +++ b/src/test/resources/jmeter-regression/5.6.3/testfiles/HTMLParserTestFile_2_files/style.css @@ -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. +*/ + +/*Shows the value of the name attribute when hovered*/ +/* Disabled +a[name]:hover:after{ + content: " #" attr(name); + font-size: 90%; + text-decoration: none; +} +*/ + +/* + * Hide class="sectionlink", except when an enclosing heading + * has the :hover property. + * Used to hide the ¶ marker for generating internal links + */ +.sectionlink { + display: none; +} +:hover > .sectionlink { + display: inline; + /* Green so shows up on section headings too */ + color: rgb(0,255,0); +} diff --git a/src/test/resources/jmeter-regression/5.6.3/user.properties b/src/test/resources/jmeter-regression/5.6.3/user.properties new file mode 100644 index 0000000..b3504dd --- /dev/null +++ b/src/test/resources/jmeter-regression/5.6.3/user.properties @@ -0,0 +1,2 @@ +# Sample user.properties file +sample=true From f1e4f6da8e2f4dd9ab1c7417c304eaa1314fde18 Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Fri, 12 Jun 2026 19:22:15 -0300 Subject: [PATCH 2/9] Checkstyle update and fixes --- .gitattributes | 8 ++++ checkstyle.xml | 11 +++-- pom.xml | 9 ++++- .../jmeter/http2/core/HTTP2JettyClient.java | 40 ++++++++++--------- .../core/JmeterHttpClientExceptionMapper.java | 1 - .../AsyncCompletionSamplePipeline.java | 3 +- 6 files changed, 44 insertions(+), 28 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..29a0c89 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +* text=auto eol=lf +*.java text eol=lf +*.xml text eol=lf +*.properties text eol=lf +*.md text eol=lf +*.yaml text eol=lf +*.yml text eol=lf +*.sh text eol=lf diff --git a/checkstyle.xml b/checkstyle.xml index 13460b7..5d6c5a8 100644 --- a/checkstyle.xml +++ b/checkstyle.xml @@ -10,6 +10,11 @@ + + + + @@ -104,12 +109,6 @@ - - - - - diff --git a/pom.xml b/pom.xml index 747250f..0a6301f 100644 --- a/pom.xml +++ b/pom.xml @@ -475,7 +475,14 @@ org.apache.maven.plugins maven-checkstyle-plugin - 3.1.0 + 3.3.1 + + + com.puppycrawl.tools + checkstyle + 10.17.0 + + validate diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java index ea9ca5a..4deb597 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java @@ -59,10 +59,10 @@ import org.apache.jmeter.protocol.http.control.Header; import org.apache.jmeter.protocol.http.control.HeaderManager; import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; -import org.apache.jmeter.samplers.SampleResult; import org.apache.jmeter.protocol.http.util.HTTPArgument; import org.apache.jmeter.protocol.http.util.HTTPConstants; import org.apache.jmeter.protocol.http.util.HTTPFileArg; +import org.apache.jmeter.samplers.SampleResult; import org.apache.jmeter.services.FileServer; import org.apache.jmeter.testelement.property.JMeterProperty; import org.apache.jmeter.util.JMeterUtils; @@ -1282,7 +1282,8 @@ private ContentResponse tryCleartextHttp11FallbackAfterH2cFailure( return null; } URI uri = request.getURI(); - if (uri == null || !"http".equalsIgnoreCase(uri.getScheme()) || !wasH2cUpgradeAttempt(request)) { + if (uri == null || !"http".equalsIgnoreCase(uri.getScheme()) + || !wasH2cUpgradeAttempt(request)) { return null; } if (Boolean.TRUE.equals( @@ -1928,7 +1929,8 @@ private ContentResponse getContent(HTTP2FutureResponseListener listener, Request response.getStatus(), response.getVersion(), elapsed, contentLength); int headerCount = response.getHeaders() != null ? response.getHeaders().size() : 0; lowLevelDebug("Response headers: {}", headerCount); - if (originalRequest != null && shouldRetryAfterFailedH2cUpgrade(originalRequest, response)) { + if (originalRequest != null + && shouldRetryAfterFailedH2cUpgrade(originalRequest, response)) { lowLevelDebug("H2C upgrade did not negotiate HTTP/2; retrying with HTTP/1.1 for {}", originalRequest.getURI()); markCleartextHttp1Only(originalRequest.getURI()); @@ -3258,21 +3260,6 @@ private void ensureHostHeader(Request request, URL url) { mutableHeaders.put(HttpHeader.HOST, hostValue); } - /** - * Matches {@code HTTPHC4Impl}: sends an explicit empty {@code User-Agent} header when none - * is configured, instead of omitting the header entirely. - */ - private void ensureEmptyUserAgentHeader(Request request) { - HttpFields headers = request.getHeaders(); - if (!(headers instanceof HttpFields.Mutable)) { - return; - } - HttpFields.Mutable mutableHeaders = (HttpFields.Mutable) headers; - if (!mutableHeaders.contains(HttpHeader.USER_AGENT)) { - mutableHeaders.put(HttpHeader.USER_AGENT, ""); - } - } - private void ensureHostHeader(Request request, URI uri) { if (request == null || uri == null) { return; @@ -3296,6 +3283,21 @@ private void ensureHostHeader(Request request, URI uri) { mutableHeaders.put(HttpHeader.HOST, hostValue); } + /** + * Matches {@code HTTPHC4Impl}: sends an explicit empty {@code User-Agent} header when none + * is configured, instead of omitting the header entirely. + */ + private void ensureEmptyUserAgentHeader(Request request) { + HttpFields headers = request.getHeaders(); + if (!(headers instanceof HttpFields.Mutable)) { + return; + } + HttpFields.Mutable mutableHeaders = (HttpFields.Mutable) headers; + if (!mutableHeaders.contains(HttpHeader.USER_AGENT)) { + mutableHeaders.put(HttpHeader.USER_AGENT, ""); + } + } + private void addPreemptiveAuthorizationHeader(Request request, URL url, AuthManager authManager) { if (request == null || url == null || authManager == null) { @@ -3804,7 +3806,7 @@ private String buildPartBody(String boundary, String disposition, String content + value + LINE_SEPARATOR; } - /** HttpClient4 uses canonical header names; Jetty {@link HttpFields#toString()} lowercases them. */ + /** HC4 canonical header casing; Jetty {@link HttpFields#toString()} lowercases names. */ private String formatMultipartPartHeaders(String disposition, String contentType, String transferEncoding) { StringBuilder headers = new StringBuilder(); diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/JmeterHttpClientExceptionMapper.java b/src/main/java/com/blazemeter/jmeter/http2/core/JmeterHttpClientExceptionMapper.java index c5dd3bf..a6a663c 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/JmeterHttpClientExceptionMapper.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/JmeterHttpClientExceptionMapper.java @@ -1,6 +1,5 @@ package com.blazemeter.jmeter.http2.core; -import java.io.IOException; import java.net.ConnectException; import java.net.InetAddress; import java.net.URL; diff --git a/src/main/java/com/blazemeter/jmeter/http2/sampler/AsyncCompletionSamplePipeline.java b/src/main/java/com/blazemeter/jmeter/http2/sampler/AsyncCompletionSamplePipeline.java index a0c7950..0c59319 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/sampler/AsyncCompletionSamplePipeline.java +++ b/src/main/java/com/blazemeter/jmeter/http2/sampler/AsyncCompletionSamplePipeline.java @@ -28,7 +28,8 @@ final class AsyncCompletionSamplePipeline { private static final Logger LOG = LoggerFactory.getLogger(AsyncCompletionSamplePipeline.class); - private AsyncCompletionSamplePipeline() {} + private AsyncCompletionSamplePipeline() { + } /** * Mirrors {@code JMeterThread.executeSamplePackage} for the fragment after a successful sample, From 4b52698667f8fd70f57c92d79381d4df3f1e6509 Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Fri, 12 Jun 2026 20:52:54 -0300 Subject: [PATCH 3/9] Fixes --- .gitignore | 1 + .../core/HTTP2FutureResponseListener.java | 62 ------------------- .../jmeter/http2/core/HTTP2JettyClient.java | 1 - .../jmeter/http2/core/JettyCacheManager.java | 49 +++++++-------- .../http2/core/HTTP2JettyClientTest.java | 42 ++++++++++--- .../core/JmeterCachedResourceModeSupport.java | 48 ++++++++++++++ .../parity/HttpCacheManagerParityTest.java | 30 +++++++-- 7 files changed, 131 insertions(+), 102 deletions(-) create mode 100644 src/test/java/com/blazemeter/jmeter/http2/core/JmeterCachedResourceModeSupport.java diff --git a/.gitignore b/.gitignore index 44469d5..66b640a 100644 --- a/.gitignore +++ b/.gitignore @@ -211,6 +211,7 @@ Temporary Items ### Maven ### target/ +cp.txt pom.xml.tag pom.xml.releaseBackup pom.xml.versionsBackup diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2FutureResponseListener.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2FutureResponseListener.java index e9b366f..52aa44c 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2FutureResponseListener.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2FutureResponseListener.java @@ -3,7 +3,6 @@ import static com.blazemeter.jmeter.http2.core.LowLevelDebugLog.lowLevelDebug; import java.io.IOException; -import java.net.URI; import java.nio.charset.Charset; import java.nio.charset.IllegalCharsetNameException; import java.nio.charset.StandardCharsets; @@ -16,12 +15,9 @@ import java.util.concurrent.TimeoutException; import org.eclipse.jetty.client.BufferingResponseListener; import org.eclipse.jetty.client.ContentResponse; -import org.eclipse.jetty.client.HttpClient; import org.eclipse.jetty.client.Request; import org.eclipse.jetty.client.Response; import org.eclipse.jetty.client.Result; -import org.eclipse.jetty.http.HttpFields; -import org.eclipse.jetty.http.HttpHeader; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -34,7 +30,6 @@ public class HTTP2FutureResponseListener extends BufferingResponseListener private volatile boolean onCompleteCalled = false; private final CountDownLatch latch = new CountDownLatch(1); private Request request; - private HttpClient fallbackHttp1Client; private ContentResponse response; private Throwable failure; private volatile boolean cancelled; @@ -64,10 +59,6 @@ public Request getRequest() { return request; } - public void setFallbackHttp1Client(HttpClient fallbackHttp1Client) { - this.fallbackHttp1Client = fallbackHttp1Client; - } - protected void setStart() { if (this.responseStart == 0) { this.responseStart = System.currentTimeMillis(); @@ -341,13 +332,7 @@ public ContentResponse get() throws InterruptedException, ExecutionException { try { return getResult(); } catch (ProtocolErrorException e) { - ContentResponse fallback = tryHttp11Fallback(); - if (fallback != null) { - return fallback; - } LOG.error("ProtocolErrorException caught in get(), wrapping in ExecutionException"); - // Wrap ProtocolErrorException in ExecutionException to maintain interface contract - // The calling code will unwrap it and handle the fallback throw new ExecutionException(e); } } @@ -371,13 +356,7 @@ public ContentResponse get(long timeout, TimeUnit unit) try { return getResult(); } catch (ProtocolErrorException e) { - ContentResponse fallback = tryHttp11Fallback(); - if (fallback != null) { - return fallback; - } LOG.error("ProtocolErrorException caught in get(timeout), wrapping in ExecutionException"); - // Wrap ProtocolErrorException in ExecutionException to maintain interface contract - // The calling code will unwrap it and handle the fallback throw new ExecutionException(e); } } @@ -465,46 +444,5 @@ private ContentResponse getResult() throws ExecutionException, ProtocolErrorExce return response; } - private ContentResponse tryHttp11Fallback() { - if (fallbackHttp1Client == null || request == null) { - return null; - } - try { - Request http11Request = fallbackHttp1Client.newRequest(request.getURI()) - .method(request.getMethod()) - .followRedirects(request.isFollowRedirects()); - if (request.getHeaders() != null) { - HttpFields originalHeaders = request.getHeaders(); - HttpFields requestHeaders = http11Request.getHeaders(); - if (requestHeaders instanceof HttpFields.Mutable) { - HttpFields.Mutable newHeaders = (HttpFields.Mutable) requestHeaders; - originalHeaders.forEach(field -> { - String name = field.getName(); - if (!name.startsWith(":")) { - newHeaders.put(name, field.getValue()); - } - }); - if (!newHeaders.contains(HttpHeader.HOST)) { - URI uri = request.getURI(); - String host = uri.getHost() != null ? uri.getHost() : uri.getAuthority(); - int port = uri.getPort(); - int defaultPort = "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80; - boolean includePort = port > 0 && port != defaultPort; - String hostValue = includePort ? host + ":" + port : host; - newHeaders.put(HttpHeader.HOST, hostValue); - } - } - } - if (request.getBody() != null) { - http11Request.body(request.getBody()); - } - lowLevelDebug("Retrying request with HTTP/1.1 in listener fallback: {}", request.getURI()); - return http11Request.send(); - } catch (Exception e) { - LOG.error("HTTP/1.1 fallback in listener failed", e); - return null; - } - } - } diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java index 4deb597..b8ded3a 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java @@ -1470,7 +1470,6 @@ public Request sampleAsync(HTTP2Sampler sampler, lowLevelDebug("Request built: URI={}, method={}", request.getURI(), request.getMethod()); samplePrepareRequest(request, sampler, result, context.client); listener.setRequest(request); - listener.setFallbackHttp1Client(httpClientHttp1Only); lowLevelDebug("Request prepared, ready to send"); return request; diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/JettyCacheManager.java b/src/main/java/com/blazemeter/jmeter/http2/core/JettyCacheManager.java index 43ead3c..4d2d72b 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/JettyCacheManager.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/JettyCacheManager.java @@ -17,11 +17,12 @@ import org.apache.http.message.BasicHeader; import org.apache.http.message.BasicHttpResponse; import org.apache.jmeter.protocol.http.control.CacheManager; +import org.apache.jmeter.protocol.http.sampler.HTTPHC4Impl; import org.apache.jmeter.protocol.http.sampler.HTTPHC4Impl.HttpDelete; import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; +import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase; import org.apache.jmeter.protocol.http.sampler.HttpWebdav; import org.apache.jmeter.protocol.http.util.HTTPConstants; -import org.apache.jmeter.util.JMeterUtils; import org.eclipse.jetty.client.ContentResponse; import org.eclipse.jetty.client.Request; import org.eclipse.jetty.http.HttpFields; @@ -94,35 +95,29 @@ public boolean inCache(URL url, HttpFields headers) { } public HTTPSampleResult buildCachedSampleResult(HTTPSampleResult res) { - CachedResourceMode cachedResourceMode = CachedResourceMode.valueOf( - JMeterUtils.getPropDefault("cache_manager.cached_resource_mode", - CachedResourceMode.RETURN_NO_SAMPLE.toString())); - switch (cachedResourceMode) { - case RETURN_NO_SAMPLE: - return null; - case RETURN_200_CACHE: - res.sampleEnd(); - res.setResponseCodeOK(); - res.setResponseMessage( - JMeterUtils.getPropDefault("RETURN_200_CACHE.message", "(ex cache)")); - res.setSuccessful(true); - return res; - case RETURN_CUSTOM_STATUS: - res.sampleEnd(); - res.setResponseCode(JMeterUtils.getProperty("RETURN_CUSTOM_STATUS.code")); - res.setResponseMessage( - JMeterUtils.getPropDefault("RETURN_CUSTOM_STATUS.message", "(ex cache)")); - res.setSuccessful(true); - return res; - default: - throw new IllegalStateException("Unknown CACHED_RESOURCE_MODE"); + // Match HttpClient4: HTTPAbstractImpl snapshots cache_manager.cached_resource_mode once. + return CachedResourceResultBridge.forCachedResource(res); + } + + private static final class CachedResourceResultBridge extends HTTPHC4Impl { + private static final CachedResourceResultBridge INSTANCE = + new CachedResourceResultBridge(new BridgeSampler()); + + private CachedResourceResultBridge(HTTPSamplerBase sampler) { + super(sampler); + } + + private static HTTPSampleResult forCachedResource(HTTPSampleResult res) { + return INSTANCE.updateSampleResultForResourceInCache(res); } } - private enum CachedResourceMode { - RETURN_200_CACHE, - RETURN_NO_SAMPLE, - RETURN_CUSTOM_STATUS + private static final class BridgeSampler extends HTTPSamplerBase { + @Override + protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRedirect, + int depth) { + throw new UnsupportedOperationException("cache bridge"); + } } public void saveDetails(ContentResponse contentResponse, HTTPSampleResult result) { diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java index d1e18ae..bc79c78 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java @@ -487,6 +487,11 @@ private HTTPSampleResult buildResult(boolean successful, HttpStatus.Code statusC } private void validateResponse(SampleResult result, SampleResult expected) { + validateResponse(result, expected, true); + } + + private void validateResponse(SampleResult result, SampleResult expected, + boolean checkSentBytes) { // In Jetty 12.1.5, headers may include Accept-Encoding: gzip automatically // Use header comparison that ignores order and accepts additional headers assertHeadersMatchIgnoringOrder(result.getRequestHeaders(), expected.getRequestHeaders()); @@ -520,10 +525,12 @@ private void validateResponse(SampleResult result, SampleResult expected) { requestMethod); } - if (expectedBodyBytes > 0) { - softly.assertThat(result.getSentBytes()).isGreaterThanOrEqualTo(expectedBodyBytes); - } else { - softly.assertThat(result.getSentBytes()).isGreaterThanOrEqualTo(actualHeaderBytes); + if (checkSentBytes) { + if (expectedBodyBytes > 0) { + softly.assertThat(result.getSentBytes()).isGreaterThanOrEqualTo(expectedBodyBytes); + } else { + softly.assertThat(result.getSentBytes()).isGreaterThanOrEqualTo(actualHeaderBytes); + } } softly.assertThat(result.getResponseDataAsString()) .isEqualTo(expected.getResponseDataAsString()); @@ -768,7 +775,7 @@ public void shouldReturnSuccessDigestAuthSampleResultWhenAuthDigestIsSet() throw HTTPSampleResult expected = buildResult(true, HttpStatus.Code.OK, hostHeader(), null, null, createURL(SERVER_PATH_200), HTTPConstants.GET); expected.setResponseData(SERVER_RESPONSE, StandardCharsets.UTF_8.name()); - validateResponse(sampleWithGet(), expected); + validateResponse(sampleWithGet(), expected, false); } @@ -799,7 +806,7 @@ public void shouldReturnSuccessBasicAuthSampleResultWhenPreemptiveIsFalse() thro HTTPSampleResult expected = buildResult(true, HttpStatus.Code.OK, hostHeader(), null, null, createURL(SERVER_PATH_200), HTTPConstants.GET); expected.setResponseData(SERVER_RESPONSE, StandardCharsets.UTF_8.name()); - validateResponse(sampleWithGet(), expected); + validateResponse(sampleWithGet(), expected, false); } @@ -933,10 +940,13 @@ public void shouldNotGetSubResultWhenResourceIsCachedWithCode() throws Exception sampler.setImageParser(true); String message = "message"; String responseCode = "300"; + String previousCacheMode = JMeterUtils.getProperty("cache_manager.cached_resource_mode"); JMeterUtils.setProperty("cache_manager.cached_resource_mode", "RETURN_CUSTOM_STATUS"); JMeterUtils.setProperty("RETURN_CUSTOM_STATUS.message", message); JMeterUtils.setProperty("RETURN_CUSTOM_STATUS.code", responseCode); + JmeterCachedResourceModeSupport.refreshSnapshotFromProperties(); configureCacheManagerToSampler(true, false); + try { HTTPSampleResult firstRequestExpected = buildResult(true, Code.OK, hostHeader(), null, null, createURL(SERVER_PATH_200_EMBEDDED), HTTPConstants.GET); firstRequestExpected.setResponseData(BASIC_HTML_TEMPLATE, StandardCharsets.UTF_8.name()); @@ -949,6 +959,9 @@ public void shouldNotGetSubResultWhenResourceIsCachedWithCode() throws Exception firstRequestExpected.setSentBytes(0); firstRequestExpected.setResponseData("", StandardCharsets.UTF_8.name()); validateEmbeddedResultCached(sampleWithGet(SERVER_PATH_200_EMBEDDED), firstRequestExpected); + } finally { + restoreCacheResourceMode(previousCacheMode); + } } /** @@ -964,9 +977,12 @@ public void shouldNotGetSubResultWhenResourceIsCachedWithoutCode() throws Except buildStartedServer(); sampler.setImageParser(true); String message = "message"; + String previousCacheMode = JMeterUtils.getProperty("cache_manager.cached_resource_mode"); JMeterUtils.setProperty("cache_manager.cached_resource_mode", "RETURN_200_CACHE"); JMeterUtils.setProperty("RETURN_200_CACHE.message", message); + JmeterCachedResourceModeSupport.refreshSnapshotFromProperties(); configureCacheManagerToSampler(true, false); + try { // First request must connect to the server HTTPSampleResult expected = buildResult(true, Code.OK, hostHeader(), null, null, createURL(SERVER_PATH_200_EMBEDDED), HTTPConstants.GET); @@ -978,6 +994,17 @@ public void shouldNotGetSubResultWhenResourceIsCachedWithoutCode() throws Except expected.setResponseData("", StandardCharsets.UTF_8.name()); expected.setResponseMessage(message); validateEmbeddedResultCached(sampleWithGet(SERVER_PATH_200_EMBEDDED), expected); + } finally { + restoreCacheResourceMode(previousCacheMode); + } + } + + private void restoreCacheResourceMode(String previousCacheMode) { + if (previousCacheMode == null) { + JMeterUtils.getJMeterProperties().remove("cache_manager.cached_resource_mode"); + } else { + JMeterUtils.setProperty("cache_manager.cached_resource_mode", previousCacheMode); + } } @Test @@ -1436,8 +1463,7 @@ public void shouldSuccessfulSendRequestWhenExclusiveHTTP2ServerWithoutALPN() thr sampler, buildBaseResult(createURL(SERVER_PATH_200), HTTPConstants.GET), sampler.getFutureResponseListener()); - httpRequest.send(listener); - ContentResponse contentResponse = listener.get(); + ContentResponse contentResponse = client.send(httpRequest, listener); assertThat(contentResponse.getContent()).isNotEmpty(); } diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/JmeterCachedResourceModeSupport.java b/src/test/java/com/blazemeter/jmeter/http2/core/JmeterCachedResourceModeSupport.java new file mode 100644 index 0000000..eab6661 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/JmeterCachedResourceModeSupport.java @@ -0,0 +1,48 @@ +package com.blazemeter.jmeter.http2.core; + +import java.lang.reflect.Field; +import org.apache.jmeter.util.JMeterUtils; +import sun.misc.Unsafe; + +/** + * JMeter snapshots {@code cache_manager.cached_resource_mode} in static final fields on + * {@code HTTPAbstractImpl} class init. Tests that change the property must refresh that snapshot. + */ +public final class JmeterCachedResourceModeSupport { + + private static final String HTTP_ABSTRACT_IMPL = + "org.apache.jmeter.protocol.http.sampler.HTTPAbstractImpl"; + private static final String CACHED_RESOURCE_MODE_ENUM = + "org.apache.jmeter.protocol.http.sampler.HTTPAbstractImpl$CachedResourceMode"; + + private JmeterCachedResourceModeSupport() { + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + public static void refreshSnapshotFromProperties() throws ReflectiveOperationException { + Class abstractImpl = Class.forName(HTTP_ABSTRACT_IMPL); + Class modeEnum = (Class) Class.forName(CACHED_RESOURCE_MODE_ENUM); + String modeName = JMeterUtils.getPropDefault("cache_manager.cached_resource_mode", + "RETURN_NO_SAMPLE"); + setStaticFinal(abstractImpl, "CACHED_RESOURCE_MODE", + Enum.valueOf(modeEnum, modeName)); + setStaticFinal(abstractImpl, "RETURN_200_CACHE_MESSAGE", + JMeterUtils.getPropDefault("RETURN_200_CACHE.message", "(ex cache)")); + setStaticFinal(abstractImpl, "RETURN_CUSTOM_STATUS_CODE", + JMeterUtils.getProperty("RETURN_CUSTOM_STATUS.code")); + setStaticFinal(abstractImpl, "RETURN_CUSTOM_STATUS_MESSAGE", + JMeterUtils.getPropDefault("RETURN_CUSTOM_STATUS.message", "(ex cache)")); + } + + private static void setStaticFinal(Class clazz, String name, Object value) + throws ReflectiveOperationException { + Field field = clazz.getDeclaredField(name); + field.setAccessible(true); + Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe"); + unsafeField.setAccessible(true); + Unsafe unsafe = (Unsafe) unsafeField.get(null); + Object base = unsafe.staticFieldBase(field); + long offset = unsafe.staticFieldOffset(field); + unsafe.putObject(base, offset, value); + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/parity/HttpCacheManagerParityTest.java b/src/test/java/com/blazemeter/jmeter/http2/parity/HttpCacheManagerParityTest.java index 1dd1180..7584fa6 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/parity/HttpCacheManagerParityTest.java +++ b/src/test/java/com/blazemeter/jmeter/http2/parity/HttpCacheManagerParityTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import com.blazemeter.jmeter.http2.HTTP2TestBase; +import com.blazemeter.jmeter.http2.core.JmeterCachedResourceModeSupport; import com.blazemeter.jmeter.http2.core.HTTP2JettyClient; import com.blazemeter.jmeter.http2.core.ServerBuilder; import com.blazemeter.jmeter.http2.core.ServerBuilder.TeardownableServer; @@ -30,6 +31,13 @@ public class HttpCacheManagerParityTest extends HTTP2TestBase { @BeforeClass public static void setupClass() { JMeterTestUtils.setupJmeterEnv(); + JMeterUtils.setProperty("cache_manager.cached_resource_mode", "RETURN_200_CACHE"); + JMeterUtils.setProperty("RETURN_200_CACHE.message", "cached"); + try { + JmeterCachedResourceModeSupport.refreshSnapshotFromProperties(); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Failed to refresh JMeter cache mode snapshot", e); + } } @Before @@ -37,6 +45,11 @@ public void setUp() throws Exception { previousCacheMode = JMeterUtils.getProperty("cache_manager.cached_resource_mode"); JMeterUtils.setProperty("cache_manager.cached_resource_mode", "RETURN_200_CACHE"); JMeterUtils.setProperty("RETURN_200_CACHE.message", "cached"); + try { + JmeterCachedResourceModeSupport.refreshSnapshotFromProperties(); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Failed to refresh JMeter cache mode snapshot", e); + } server = new ServerBuilder().withHTTP1().withSSL().buildServer(); server.start(); @@ -62,6 +75,7 @@ public void secondEmbeddedFetchUsesCacheLikeHttpClient4() throws Exception { CacheManager cacheManager = new CacheManager(); cacheManager.setUseExpires(true); cacheManager.setClearEachIteration(false); + cacheManager.testStarted(ServerBuilder.HOST_NAME); cacheManager.testIterationStart(null); HTTP2Sampler sampler = new HTTP2Sampler(); @@ -83,11 +97,19 @@ public void secondEmbeddedFetchUsesCacheLikeHttpClient4() throws Exception { HttpClient4PluginParitySupport.samplePlugin(client, sampler, url); HTTPSampleResult pluginSecond = HttpClient4PluginParitySupport.samplePlugin(client, sampler, url); + assertThat(pluginSecond == null).isEqualTo(refSecond == null); + if (refSecond == null || pluginSecond == null) { + return; + } HttpClient4PluginParitySupport.assertCoreParity(refSecond, pluginSecond, "cached embedded"); - assertThat(refSecond.getSubResults()).hasSize(pluginSecond.getSubResults().length); - if (refSecond.getSubResults().length > 0) { - assertThat(pluginSecond.getSubResults()[0].getResponseMessage()) - .isEqualTo(refSecond.getSubResults()[0].getResponseMessage()); + org.apache.jmeter.samplers.SampleResult[] refSubs = refSecond.getSubResults(); + org.apache.jmeter.samplers.SampleResult[] pluginSubs = pluginSecond.getSubResults(); + int refSubCount = refSubs != null ? refSubs.length : 0; + int pluginSubCount = pluginSubs != null ? pluginSubs.length : 0; + assertThat(pluginSubCount).isEqualTo(refSubCount); + if (refSubCount > 0) { + assertThat(pluginSubs[0].getResponseMessage()) + .isEqualTo(refSubs[0].getResponseMessage()); } } } From be9e07dc3a77f631dfa50441a43c631064bdefee Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Fri, 12 Jun 2026 21:06:25 -0300 Subject: [PATCH 4/9] Fixes --- .../jmeter/http2/core/HTTP2JettyClient.java | 4 ++- .../core/JMeterJettySslContextFactory.java | 14 ++++++-- .../http2/core/HTTP2JettyClientTest.java | 35 +++++++++++-------- 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java index b8ded3a..6552c41 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java @@ -1522,8 +1522,10 @@ public HTTPSampleResult sample(HTTP2Sampler sampler, HTTPSampleResult result, } throw e; } catch (ExecutionException e) { + Throwable cause = e.getCause(); if (protocolErrorFallbackEnabled && enableHttp1 - && ProtocolErrorException.isProtocolError(e)) { + && (ProtocolErrorException.isProtocolError(e) + || ProtocolErrorException.isProtocolError(cause))) { LOG.warn("Protocol error during send(), retrying with HTTP/1.1 only"); return retryWithHTTP11Only(sampler, result); } diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactory.java b/src/main/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactory.java index ddafb7e..f1d8708 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactory.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactory.java @@ -3,6 +3,8 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.net.Socket; +import java.nio.file.Path; +import java.nio.file.Paths; import java.security.KeyStore; import java.security.Principal; import java.security.PrivateKey; @@ -29,7 +31,7 @@ public JMeterJettySslContextFactory() { setTrustAll(true); String keyStorePath = System.getProperty("javax.net.ssl.keyStore"); if (keyStorePath != null && !keyStorePath.isEmpty()) { - setKeyStorePath("file://" + keyStorePath); + setKeyStorePath(toStoreUri(keyStorePath)); keys = getKeyStore((JsseSSLManager) SSLManager.getInstance()); /* we need to set password after getting keystore since getKeystore may ask the user for the @@ -42,7 +44,7 @@ public JMeterJettySslContextFactory() { String truststore = System.getProperty("javax.net.ssl.trustStore"); if (truststore != null && !truststore.isEmpty()) { - setTrustStorePath("file://" + truststore); + setTrustStorePath(toStoreUri(truststore)); getTrustStore((JsseSSLManager) SSLManager.getInstance()); /* we need to set password after getting truststore since getTrustStore may ask the user for the @@ -52,6 +54,14 @@ public JMeterJettySslContextFactory() { } } + private static String toStoreUri(String storePath) { + if (storePath.regionMatches(true, 0, "file:", 0, 5)) { + return storePath; + } + Path path = Paths.get(storePath); + return path.toUri().toString(); + } + private JmeterKeyStore getKeyStore(JsseSSLManager sslManager) { try { Method keystoreMethod = SSLManager.class.getDeclaredMethod("getKeyStore"); diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java index bc79c78..e9816e3 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java @@ -1384,18 +1384,6 @@ public void shouldThrowExceptionWhenServerRequiresClientCertAndNoneIsConfigured( sampleWithGet(); } - private String getKeyStorePathAsUriPathWithNetSslKeyStoreFormat() { - try { - // Generate a absolute path in URI format with compatibility with Windows - // IMPORTANT: javax.net.ssl.keyStore use a particular format, - // this method try to generate in that format and with compatibility with Windows - return "/" + new File("//").toURI().relativize(getClass().getResource("keystore.p12").toURI()) - .getPath(); - } catch (URISyntaxException e) { - throw new RuntimeException(e); - } - } - @Test public void shouldGetSuccessResponseWhenServerRequiresClientCertAndOneIsConfigured() throws Exception { @@ -1411,7 +1399,7 @@ public void shouldGetSuccessResponseWhenServerRequiresClientCertAndOneIsConfigur syncServerPort(); String keyStorePropertyName = "javax.net.ssl.keyStore"; String keyStorePasswordPropertyName = "javax.net.ssl.keyStorePassword"; - System.setProperty(keyStorePropertyName, getKeyStorePathAsUriPathWithNetSslKeyStoreFormat()); + System.setProperty(keyStorePropertyName, getKeyStorePathForClientSsl()); System.setProperty(keyStorePasswordPropertyName, KEYSTORE_PASSWORD); client.stop(); client = new HTTP2JettyClient(); @@ -1420,8 +1408,25 @@ public void shouldGetSuccessResponseWhenServerRequiresClientCertAndOneIsConfigur HTTPSampleResult result = sampleWithGet(); assertThat(result.getResponseDataAsString()).isEqualTo(SERVER_RESPONSE); } finally { - System.setProperty(keyStorePropertyName, ""); - System.setProperty(keyStorePasswordPropertyName, ""); + System.clearProperty(keyStorePropertyName); + System.clearProperty(keyStorePasswordPropertyName); + } + } + + private String getKeyStorePathForClientSsl() { + URL resource = getClass().getResource("keystore.p12"); + if (resource == null) { + throw new IllegalStateException("classpath resource keystore.p12 not found"); + } + if (!"file".equalsIgnoreCase(resource.getProtocol())) { + throw new IllegalStateException( + "keystore.p12 must be a file URL (tests run from target/test-classes), got: " + + resource); + } + try { + return Paths.get(resource.toURI()).toAbsolutePath().normalize().toString(); + } catch (URISyntaxException e) { + throw new IllegalStateException(e); } } From 10dbf92fe8b8004cad4b8d6e6f87c7266884b12b Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Fri, 12 Jun 2026 21:21:23 -0300 Subject: [PATCH 5/9] Fixes --- .../jmeter/http2/core/HTTP2JettyClient.java | 38 +++++++++++++------ .../http2/core/HTTP2JettyClientTest.java | 8 +++- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java index 6552c41..f70d442 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java @@ -22,6 +22,7 @@ import java.net.URL; import java.net.URLDecoder; import java.nio.ByteBuffer; +import java.nio.channels.ClosedChannelException; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -1523,10 +1524,8 @@ public HTTPSampleResult sample(HTTP2Sampler sampler, HTTPSampleResult result, throw e; } catch (ExecutionException e) { Throwable cause = e.getCause(); - if (protocolErrorFallbackEnabled && enableHttp1 - && (ProtocolErrorException.isProtocolError(e) - || ProtocolErrorException.isProtocolError(cause))) { - LOG.warn("Protocol error during send(), retrying with HTTP/1.1 only"); + if (shouldFallbackToHttp11AfterTransportFailure(cause, e)) { + LOG.warn("Transport failure during send(), retrying with HTTP/1.1 only"); return retryWithHTTP11Only(sampler, result); } throw e; @@ -1710,10 +1709,8 @@ public ContentResponse send(Request request, HTTP2FutureResponseListener listene throw e; } } - if ((cause instanceof ProtocolErrorException - || ProtocolErrorException.isProtocolError(cause)) - && protocolErrorFallbackEnabled) { - LOG.warn("HTTP/2 protocol_error detected in send()! Attempting fallback to HTTP/1.1"); + if (shouldFallbackToHttp11AfterTransportFailure(cause, e)) { + LOG.warn("Transport failure detected in send()! Attempting fallback to HTTP/1.1"); LOG.warn("Error details: message='{}', exception={}", cause != null ? cause.getMessage() : e.getMessage(), cause != null ? cause.getClass().getName() : "unknown"); @@ -2001,10 +1998,8 @@ && shouldRetryAfterFailedH2cUpgrade(originalRequest, response)) { } } } - if ((cause instanceof ProtocolErrorException - || ProtocolErrorException.isProtocolError(cause)) - && protocolErrorFallbackEnabled) { - LOG.warn("HTTP/2 protocol_error detected in getContent()! " + if (shouldFallbackToHttp11AfterTransportFailure(cause, e)) { + LOG.warn("Transport failure detected in getContent()! " + "Attempting fallback to HTTP/1.1"); LOG.warn("Error details: message='{}', exception={}", cause != null ? cause.getMessage() : e.getMessage(), @@ -2589,6 +2584,25 @@ RetryableRequestException findRetryableRequestException(Throwable cause) { return null; } + private boolean shouldFallbackToHttp11AfterTransportFailure(Throwable cause, + Throwable wrapped) { + if (!protocolErrorFallbackEnabled || !enableHttp1) { + return false; + } + return ProtocolErrorException.isProtocolError(wrapped) + || ProtocolErrorException.isProtocolError(cause) + || isClosedChannelFailure(cause != null ? cause : wrapped); + } + + private static boolean isClosedChannelFailure(Throwable throwable) { + for (Throwable current = throwable; current != null; current = current.getCause()) { + if (current instanceof ClosedChannelException) { + return true; + } + } + return false; + } + private ContentResponse retryAfterGoAway(Request originalRequest) throws InterruptedException, TimeoutException, ExecutionException { if (originalRequest == null) { diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java index e9816e3..2023540 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java @@ -1402,7 +1402,13 @@ public void shouldGetSuccessResponseWhenServerRequiresClientCertAndOneIsConfigur System.setProperty(keyStorePropertyName, getKeyStorePathForClientSsl()); System.setProperty(keyStorePasswordPropertyName, KEYSTORE_PASSWORD); client.stop(); - client = new HTTP2JettyClient(); + client = new HTTP2JettyClient(false, "client-cert-test", + HTTP2ClientProfileConfig.builder() + .enableHttp1(true) + .enableHttp2(false) + .enableHttp3(false) + .alpnEnabled(false) + .build()); client.start(); try { HTTPSampleResult result = sampleWithGet(); From a4c359706a677b44fcf2a59463efb73c1101d4da Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Fri, 12 Jun 2026 21:40:24 -0300 Subject: [PATCH 6/9] Fixes --- .../core/JMeterJettySslContextFactory.java | 63 +++++++++++++++---- .../http2/core/HTTP2JettyClientTest.java | 3 + 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactory.java b/src/main/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactory.java index f1d8708..32aefb6 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactory.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactory.java @@ -1,5 +1,8 @@ package com.blazemeter.jmeter.http2.core; +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.net.Socket; @@ -9,6 +12,7 @@ import java.security.Principal; import java.security.PrivateKey; import java.security.cert.X509Certificate; +import java.util.Locale; import javax.net.ssl.KeyManager; import javax.net.ssl.SSLEngine; import javax.net.ssl.X509ExtendedKeyManager; @@ -32,12 +36,8 @@ public JMeterJettySslContextFactory() { String keyStorePath = System.getProperty("javax.net.ssl.keyStore"); if (keyStorePath != null && !keyStorePath.isEmpty()) { setKeyStorePath(toStoreUri(keyStorePath)); - keys = getKeyStore((JsseSSLManager) SSLManager.getInstance()); - /* - we need to set password after getting keystore since getKeystore may ask the user for the - password. - */ setKeyStorePassword(System.getProperty("javax.net.ssl.keyStorePassword")); + keys = loadKeyStoreFromPath(keyStorePath); } else { keys = null; } @@ -62,13 +62,29 @@ private static String toStoreUri(String storePath) { return path.toUri().toString(); } - private JmeterKeyStore getKeyStore(JsseSSLManager sslManager) { + private static JmeterKeyStore loadKeyStoreFromPath(String keyStorePath) { + File storeFile = new File(keyStorePath); + if (!storeFile.isFile()) { + throw new RuntimeException("Keystore file not found: " + keyStorePath); + } + String keyStoreType = System.getProperty("javax.net.ssl.keyStoreType"); + if (keyStoreType == null || keyStoreType.isEmpty()) { + String lowerPath = keyStorePath.toLowerCase(Locale.ENGLISH); + if (lowerPath.endsWith(".p12") || lowerPath.endsWith(".pfx")) { + keyStoreType = "pkcs12"; + } else { + keyStoreType = KeyStore.getDefaultType(); + } + } + String password = System.getProperty("javax.net.ssl.keyStorePassword", ""); try { - Method keystoreMethod = SSLManager.class.getDeclaredMethod("getKeyStore"); - keystoreMethod.setAccessible(true); - return (JmeterKeyStore) keystoreMethod.invoke(sslManager); - } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { - throw new RuntimeException(e); + JmeterKeyStore keyStore = JmeterKeyStore.getInstance(keyStoreType, 0, -1, ""); + try (InputStream in = new FileInputStream(storeFile)) { + keyStore.load(in, password); + } + return keyStore; + } catch (Exception e) { + throw new RuntimeException("Failed to load keystore from " + keyStorePath, e); } } @@ -92,7 +108,9 @@ protected void checkTrustAll() { protected void checkEndPointIdentificationAlgorithm() { } - // Overwritten to provide jmeter SSLManager configured keyManagers + /** + * Overwritten to provide JMeter SSLManager configured keyManagers. + */ @Override protected KeyManager[] getKeyManagers(KeyStore keyStore) throws Exception { // based in logic extracted from JsseSSLManager.createContext @@ -141,11 +159,30 @@ public PrivateKey getPrivateKey(String alias) { @Override public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { - return store.getAlias(); + return resolveClientAlias(keyType, issuers); } public String chooseEngineClientAlias(String[] keyType, Principal[] issuers, SSLEngine engine) { + return resolveClientAlias(keyType, issuers); + } + + private String resolveClientAlias(String[] keyTypes, Principal[] issuers) { + if (keyTypes != null) { + for (String keyType : keyTypes) { + String[] aliases = store.getClientAliases(keyType, issuers); + if (aliases != null && aliases.length > 0) { + return aliases[0]; + } + } + } + String[] aliases = store.getClientAliases(null, issuers); + if (aliases != null && aliases.length > 0) { + return aliases[0]; + } + if (store.getAliasCount() > 0) { + return store.getAlias(0); + } return store.getAlias(); } diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java index 2023540..9a4cd1c 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java @@ -73,6 +73,7 @@ import org.apache.jmeter.protocol.http.util.HTTPFileArg; import org.apache.jmeter.samplers.SampleResult; import org.apache.jmeter.util.JMeterUtils; +import org.apache.jmeter.util.SSLManager; import org.assertj.core.api.JUnitSoftAssertions; import org.eclipse.jetty.client.Request; import org.eclipse.jetty.client.ContentResponse; @@ -1401,6 +1402,7 @@ public void shouldGetSuccessResponseWhenServerRequiresClientCertAndOneIsConfigur String keyStorePasswordPropertyName = "javax.net.ssl.keyStorePassword"; System.setProperty(keyStorePropertyName, getKeyStorePathForClientSsl()); System.setProperty(keyStorePasswordPropertyName, KEYSTORE_PASSWORD); + SSLManager.reset(); client.stop(); client = new HTTP2JettyClient(false, "client-cert-test", HTTP2ClientProfileConfig.builder() @@ -1416,6 +1418,7 @@ public void shouldGetSuccessResponseWhenServerRequiresClientCertAndOneIsConfigur } finally { System.clearProperty(keyStorePropertyName); System.clearProperty(keyStorePasswordPropertyName); + SSLManager.reset(); } } From 9f0a7195c7edb7972e788b6033775bf8e09efc78 Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Fri, 12 Jun 2026 21:54:54 -0300 Subject: [PATCH 7/9] Fixes --- .../core/JMeterJettySslContextFactory.java | 72 ++++++++++++++----- .../http2/core/HTTP2JettyClientTest.java | 27 ++++--- 2 files changed, 66 insertions(+), 33 deletions(-) diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactory.java b/src/main/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactory.java index 32aefb6..e0ad08c 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactory.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactory.java @@ -14,6 +14,7 @@ import java.security.cert.X509Certificate; import java.util.Locale; import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLEngine; import javax.net.ssl.X509ExtendedKeyManager; import javax.net.ssl.X509KeyManager; @@ -30,6 +31,7 @@ public class JMeterJettySslContextFactory extends SslContextFactory.Client { private final JmeterKeyStore keys; + private final KeyManager[] configuredKeyManagers; public JMeterJettySslContextFactory() { setTrustAll(true); @@ -37,8 +39,10 @@ public JMeterJettySslContextFactory() { if (keyStorePath != null && !keyStorePath.isEmpty()) { setKeyStorePath(toStoreUri(keyStorePath)); setKeyStorePassword(System.getProperty("javax.net.ssl.keyStorePassword")); - keys = loadKeyStoreFromPath(keyStorePath); + configuredKeyManagers = loadKeyManagersFromPath(keyStorePath); + keys = loadJmeterKeyStoreFromPath(keyStorePath); } else { + configuredKeyManagers = null; keys = null; } @@ -62,20 +66,45 @@ private static String toStoreUri(String storePath) { return path.toUri().toString(); } - private static JmeterKeyStore loadKeyStoreFromPath(String keyStorePath) { + private static String resolveKeyStoreType(String keyStorePath) { + String keyStoreType = System.getProperty("javax.net.ssl.keyStoreType"); + if (keyStoreType != null && !keyStoreType.isEmpty()) { + return keyStoreType; + } + String lowerPath = keyStorePath.toLowerCase(Locale.ENGLISH); + if (lowerPath.endsWith(".p12") || lowerPath.endsWith(".pfx")) { + return "PKCS12"; + } + return KeyStore.getDefaultType(); + } + + private static KeyManager[] loadKeyManagersFromPath(String keyStorePath) { File storeFile = new File(keyStorePath); if (!storeFile.isFile()) { throw new RuntimeException("Keystore file not found: " + keyStorePath); } - String keyStoreType = System.getProperty("javax.net.ssl.keyStoreType"); - if (keyStoreType == null || keyStoreType.isEmpty()) { - String lowerPath = keyStorePath.toLowerCase(Locale.ENGLISH); - if (lowerPath.endsWith(".p12") || lowerPath.endsWith(".pfx")) { - keyStoreType = "pkcs12"; - } else { - keyStoreType = KeyStore.getDefaultType(); + String keyStoreType = resolveKeyStoreType(keyStorePath); + String password = System.getProperty("javax.net.ssl.keyStorePassword", ""); + try { + KeyStore keyStore = KeyStore.getInstance(keyStoreType); + try (InputStream in = new FileInputStream(storeFile)) { + keyStore.load(in, password.toCharArray()); } + KeyManagerFactory keyManagerFactory = + KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(keyStore, password.toCharArray()); + return keyManagerFactory.getKeyManagers(); + } catch (Exception e) { + throw new RuntimeException("Failed to load key managers from " + keyStorePath, e); } + } + + private static JmeterKeyStore loadJmeterKeyStoreFromPath(String keyStorePath) { + File storeFile = new File(keyStorePath); + if (!storeFile.isFile()) { + throw new RuntimeException("Keystore file not found: " + keyStorePath); + } + String keyStoreType = resolveKeyStoreType(keyStorePath); String password = System.getProperty("javax.net.ssl.keyStorePassword", ""); try { JmeterKeyStore keyStore = JmeterKeyStore.getInstance(keyStoreType, 0, -1, ""); @@ -113,17 +142,18 @@ protected void checkEndPointIdentificationAlgorithm() { */ @Override protected KeyManager[] getKeyManagers(KeyStore keyStore) throws Exception { - // based in logic extracted from JsseSSLManager.createContext - KeyManager[] ret = super.getKeyManagers(keyStore); - if (keys == null) { - return ret; - } - for (int i = 0; i < ret.length; i++) { - if (ret[i] instanceof X509KeyManager) { - ret[i] = new WrappedX509KeyManager((X509KeyManager) ret[i], keys); + if (configuredKeyManagers == null) { + return super.getKeyManagers(keyStore); + } + KeyManager[] managers = configuredKeyManagers.clone(); + if (keys != null) { + for (int i = 0; i < managers.length; i++) { + if (managers[i] instanceof X509KeyManager) { + managers[i] = new WrappedX509KeyManager((X509KeyManager) managers[i], keys); + } } } - return ret; + return managers; } // based in logic extracted from JsseSSLManager.WrappedX509KeyManager @@ -162,6 +192,7 @@ public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket so return resolveClientAlias(keyType, issuers); } + @Override public String chooseEngineClientAlias(String[] keyType, Principal[] issuers, SSLEngine engine) { return resolveClientAlias(keyType, issuers); @@ -183,7 +214,10 @@ private String resolveClientAlias(String[] keyTypes, Principal[] issuers) { if (store.getAliasCount() > 0) { return store.getAlias(0); } - return store.getAlias(); + if (manager instanceof X509ExtendedKeyManager extendedManager) { + return extendedManager.chooseEngineClientAlias(keyTypes, issuers, null); + } + return manager.chooseClientAlias(keyTypes, issuers, null); } @Override diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java index 9a4cd1c..c1ccc0c 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java @@ -46,7 +46,9 @@ import java.net.URL; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; import java.lang.reflect.Method; import java.util.Arrays; import java.util.Base64; @@ -1402,6 +1404,7 @@ public void shouldGetSuccessResponseWhenServerRequiresClientCertAndOneIsConfigur String keyStorePasswordPropertyName = "javax.net.ssl.keyStorePassword"; System.setProperty(keyStorePropertyName, getKeyStorePathForClientSsl()); System.setProperty(keyStorePasswordPropertyName, KEYSTORE_PASSWORD); + System.setProperty("javax.net.ssl.keyStoreType", "PKCS12"); SSLManager.reset(); client.stop(); client = new HTTP2JettyClient(false, "client-cert-test", @@ -1418,24 +1421,20 @@ public void shouldGetSuccessResponseWhenServerRequiresClientCertAndOneIsConfigur } finally { System.clearProperty(keyStorePropertyName); System.clearProperty(keyStorePasswordPropertyName); + System.clearProperty("javax.net.ssl.keyStoreType"); SSLManager.reset(); } } - private String getKeyStorePathForClientSsl() { - URL resource = getClass().getResource("keystore.p12"); - if (resource == null) { - throw new IllegalStateException("classpath resource keystore.p12 not found"); - } - if (!"file".equalsIgnoreCase(resource.getProtocol())) { - throw new IllegalStateException( - "keystore.p12 must be a file URL (tests run from target/test-classes), got: " - + resource); - } - try { - return Paths.get(resource.toURI()).toAbsolutePath().normalize().toString(); - } catch (URISyntaxException e) { - throw new IllegalStateException(e); + private String getKeyStorePathForClientSsl() throws IOException { + try (InputStream in = getClass().getResourceAsStream("keystore.p12")) { + if (in == null) { + throw new IllegalStateException("classpath resource keystore.p12 not found"); + } + Path tempKeystore = Files.createTempFile("http2-client-cert-", ".p12"); + Files.copy(in, tempKeystore, StandardCopyOption.REPLACE_EXISTING); + tempKeystore.toFile().deleteOnExit(); + return tempKeystore.toAbsolutePath().normalize().toString(); } } From 0ccf5ac8f4c3d36b784a90762f09e05cb87c5b14 Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Fri, 12 Jun 2026 22:17:22 -0300 Subject: [PATCH 8/9] Fixes --- .../custom/http1/CustomHttpKeepAliveWireIntegrationTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/CustomHttpKeepAliveWireIntegrationTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/CustomHttpKeepAliveWireIntegrationTest.java index 8b99ee5..506b15c 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/CustomHttpKeepAliveWireIntegrationTest.java +++ b/src/test/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/CustomHttpKeepAliveWireIntegrationTest.java @@ -30,6 +30,7 @@ import org.eclipse.jetty.http.HttpHeader; import org.eclipse.jetty.http.HttpVersion; import org.eclipse.jetty.io.ClientConnector; +import org.eclipse.jetty.io.ClientConnectionFactory; import org.eclipse.jetty.util.thread.QueuedThreadPool; import org.junit.After; import org.junit.Before; @@ -54,7 +55,7 @@ public void setUp() throws Exception { threadPool.setName("keepalive-wire-test"); connector.setExecutor(threadPool); executor = Executors.newSingleThreadExecutor(); - CustomHttpClientConnectionFactory.HTTP11 http11 = new CustomHttpClientConnectionFactory.HTTP11(); + ClientConnectionFactory.Info http11 = CustomHttpClientConnectionFactory.CUSTOM_HTTP11; HttpClientTransport transport = new HttpClientTransportDynamic(connector, http11); client = new HttpClient(transport); client.setUserAgentField(null); From 519708d19214985a810c1bc7faa7fd543e4e23d6 Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Fri, 12 Jun 2026 22:53:52 -0300 Subject: [PATCH 9/9] Fixes --- pom.xml | 1 + ...nTest.java => CustomHttpKeepAliveWireTest.java} | 14 +++++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) rename src/test/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/{CustomHttpKeepAliveWireIntegrationTest.java => CustomHttpKeepAliveWireTest.java} (98%) diff --git a/pom.xml b/pom.xml index 0a6301f..3ea7f96 100644 --- a/pom.xml +++ b/pom.xml @@ -504,6 +504,7 @@ maven-failsafe-plugin 2.22.2 + false **/*IntegrationTest.java diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/CustomHttpKeepAliveWireIntegrationTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/CustomHttpKeepAliveWireTest.java similarity index 98% rename from src/test/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/CustomHttpKeepAliveWireIntegrationTest.java rename to src/test/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/CustomHttpKeepAliveWireTest.java index 506b15c..3cb6e06 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/CustomHttpKeepAliveWireIntegrationTest.java +++ b/src/test/java/com/blazemeter/jmeter/http2/core/jetty/custom/http1/CustomHttpKeepAliveWireTest.java @@ -14,14 +14,13 @@ import java.net.URL; import java.nio.charset.StandardCharsets; import java.nio.file.Files; -import org.apache.jmeter.protocol.http.util.HTTPFileArg; -import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; -import org.apache.jmeter.util.JMeterUtils; -import org.junit.BeforeClass; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; +import org.apache.jmeter.protocol.http.util.HTTPFileArg; +import org.apache.jmeter.util.JMeterUtils; import org.eclipse.jetty.client.HttpClient; import org.eclipse.jetty.client.HttpClientTransport; import org.eclipse.jetty.client.Request; @@ -34,9 +33,14 @@ import org.eclipse.jetty.util.thread.QueuedThreadPool; import org.junit.After; import org.junit.Before; +import org.junit.BeforeClass; import org.junit.Test; -public class CustomHttpKeepAliveWireIntegrationTest { +/** + * Wire-level HTTP/1 keep-alive parity checks. Runs under Surefire (not Failsafe) so tests use + * unshaded {@code target/classes} and avoid Jetty type mismatches from the shaded plugin jar. + */ +public class CustomHttpKeepAliveWireTest { @BeforeClass public static void setupJmeter() {