for the full
+ * license.
+ */
+package com.vaadin.testbench.loadtest;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.vaadin.flow.server.ErrorEvent;
+import com.vaadin.flow.server.ErrorHandler;
+
+/**
+ * Error handler for load testing that propagates exceptions instead of
+ * swallowing them.
+ *
+ * Vaadin's {@code DefaultErrorHandler} catches all exceptions during UIDL
+ * processing and shows a notification in the UI. The HTTP response stays 200
+ * with valid UIDL, making it invisible to k6 load tests.
+ *
+ * This handler re-throws exceptions as {@link RuntimeException}, causing Vaadin
+ * to return an error meta response (e.g., {@code {"meta":{"appError":...}}})
+ * that k6 checks can detect and fail on.
+ *
+ * Auto-registered via {@link LoadTestServiceInitListener}. Just add
+ * {@code loadtest-helper} as a dependency — no code changes needed.
+ *
+ * @see LoadTestServiceInitListener
+ */
+public class LoadTestErrorHandler implements ErrorHandler {
+
+ private static final Logger log = LoggerFactory
+ .getLogger(LoadTestErrorHandler.class);
+
+ @Override
+ public void error(ErrorEvent event) {
+ Throwable throwable = event.getThrowable();
+ log.error("Error during request processing", throwable);
+
+ // Re-throw so Vaadin returns an error response instead of status 200
+ if (throwable instanceof RuntimeException re) {
+ throw re;
+ }
+ throw new RuntimeException("Server error during load test", throwable);
+ }
+}
diff --git a/vaadin-testbench-loadtest/loadtest-helper/src/main/java/com/vaadin/testbench/loadtest/LoadTestItHelper.java b/vaadin-testbench-loadtest/loadtest-helper/src/main/java/com/vaadin/testbench/loadtest/LoadTestItHelper.java
new file mode 100644
index 000000000..00d8368a1
--- /dev/null
+++ b/vaadin-testbench-loadtest/loadtest-helper/src/main/java/com/vaadin/testbench/loadtest/LoadTestItHelper.java
@@ -0,0 +1,123 @@
+/**
+ * Copyright (C) 2000-2026 Vaadin Ltd
+ *
+ * This program is available under Vaadin Commercial License and Service Terms.
+ *
+ * See for the full
+ * license.
+ */
+package com.vaadin.testbench.loadtest;
+
+import org.openqa.selenium.Proxy;
+import org.openqa.selenium.WebDriver;
+import org.openqa.selenium.chrome.ChromeDriver;
+import org.openqa.selenium.chrome.ChromeOptions;
+
+import com.vaadin.testbench.TestBench;
+
+/**
+ * Static helper for load test integration tests.
+ *
+ * Provides proxy configuration for k6 recording and deployment URL helpers.
+ * When the system property {@code k6.proxy.host} is set (e.g.,
+ * {@code k6.proxy.host=localhost:6000}), the browser is configured to route
+ * traffic through a recording proxy, enabling automatic conversion of TestBench
+ * tests to k6 load tests.
+ */
+public final class LoadTestItHelper {
+
+ private static final String PROXY_HOST_PROPERTY = "k6.proxy.host";
+
+ private LoadTestItHelper() {
+ }
+
+ /**
+ * If proxy recording is enabled via the {@code k6.proxy.host} system
+ * property, quits the given driver and returns a new proxy-configured
+ * driver. Otherwise navigates the existing driver to the given URL.
+ *
+ * Typical usage in a {@code @BeforeEach} method:
+ *
+ *
+ * setDriver(LoadTestItHelper.openWithProxy(getDriver(), viewUrl));
+ *
+ *
+ * @param currentDriver
+ * the current WebDriver instance
+ * @param viewUrl
+ * the full URL to navigate to
+ * @return the driver to use (either the original or a new proxy-configured
+ * one)
+ */
+ public static WebDriver openWithProxy(WebDriver currentDriver,
+ String viewUrl) {
+ String proxyHost = System.getProperty(PROXY_HOST_PROPERTY);
+ WebDriver driver = currentDriver;
+ if (proxyHost != null && !proxyHost.isEmpty()) {
+ if (currentDriver != null) {
+ currentDriver.quit();
+ }
+ driver = createProxyDriver(proxyHost);
+ }
+ driver.get(viewUrl);
+ return driver;
+ }
+
+ /**
+ * Creates a ChromeDriver configured with proxy settings for k6 recording.
+ */
+ private static WebDriver createProxyDriver(String proxyHost) {
+ ChromeOptions options = new ChromeOptions();
+
+ Proxy proxy = new Proxy();
+ proxy.setHttpProxy(proxyHost);
+ proxy.setSslProxy(proxyHost);
+ options.setProxy(proxy);
+
+ // Required for MITM proxy to work with HTTPS
+ options.addArguments("--ignore-certificate-errors");
+ // Force localhost traffic through proxy (don't bypass loopback)
+ options.addArguments("--proxy-bypass-list=<-loopback>");
+ options.setAcceptInsecureCerts(true);
+
+ return TestBench.createDriver(new ChromeDriver(options));
+ }
+
+ /**
+ * Returns the URL to the root of the server, e.g. "http://localhost:8888"
+ *
+ * @return the URL to the root
+ */
+ public static String getRootURL() {
+ return "http://" + getDeploymentHostname() + ":" + getDeploymentPort();
+ }
+
+ /**
+ * Returns the hostname of the deployment under test. If the environment
+ * variable {@code HOSTNAME} is set (e.g., on CI), that value is used;
+ * otherwise defaults to {@code localhost}.
+ *
+ * @return the host name
+ */
+ public static String getDeploymentHostname() {
+ String hostname = System.getenv("HOSTNAME");
+ if (hostname != null && !hostname.isEmpty()) {
+ return hostname;
+ }
+ return "localhost";
+ }
+
+ /**
+ * Returns the port of the deployment under test. Configurable via the
+ * system property {@code server.port}; defaults to {@code 8080}.
+ *
+ * @return the port number
+ */
+ public static int getDeploymentPort() {
+ String port = System.getProperty("server.port");
+ if (port != null && !port.isEmpty()) {
+ return Integer.parseInt(port);
+ }
+ return 8080;
+ }
+}
diff --git a/vaadin-testbench-loadtest/loadtest-helper/src/main/java/com/vaadin/testbench/loadtest/LoadTestMetrics.java b/vaadin-testbench-loadtest/loadtest-helper/src/main/java/com/vaadin/testbench/loadtest/LoadTestMetrics.java
new file mode 100644
index 000000000..7b3bbe1e2
--- /dev/null
+++ b/vaadin-testbench-loadtest/loadtest-helper/src/main/java/com/vaadin/testbench/loadtest/LoadTestMetrics.java
@@ -0,0 +1,123 @@
+/**
+ * Copyright (C) 2000-2026 Vaadin Ltd
+ *
+ * This program is available under Vaadin Commercial License and Service Terms.
+ *
+ * See for the full
+ * license.
+ */
+package com.vaadin.testbench.loadtest;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.vaadin.flow.component.Component;
+import com.vaadin.flow.component.UI;
+
+/**
+ * Tracks active Vaadin UIs and view counts, exposing them as Micrometer metrics
+ * via Spring Boot Actuator (if Micrometer is on the classpath).
+ *
+ * Registers gauges on Micrometer's {@code Metrics.globalRegistry}, which Spring
+ * Boot auto-populates with its managed registries. If Micrometer is not
+ * present, tracking still works internally but no metrics are exposed.
+ *
+ * Auto-registered via {@link LoadTestServiceInitListener} — no code changes
+ * needed.
+ */
+class LoadTestMetrics {
+
+ private static final Logger log = LoggerFactory
+ .getLogger(LoadTestMetrics.class);
+
+ private final Set activeUis = Collections
+ .synchronizedSet(new HashSet<>());
+ private final Map, AtomicInteger> viewCounters = new ConcurrentHashMap<>();
+ private final boolean micrometerAvailable;
+
+ LoadTestMetrics() {
+ micrometerAvailable = isMicrometerAvailable();
+ if (micrometerAvailable) {
+ registerTotalGauge();
+ log.debug(
+ "LoadTestMetrics: Micrometer detected, gauges registered");
+ } else {
+ log.debug(
+ "LoadTestMetrics: Micrometer not on classpath, metrics tracking only");
+ }
+ }
+
+ /**
+ * Registers UI lifecycle listeners for tracking.
+ */
+ void trackUI(UI ui) {
+ ui.addAfterNavigationListener(navEvent -> {
+ activeUis.add(ui);
+
+ Component currentView = ui.getCurrentView();
+ Class extends Component> viewClass = currentView.getClass();
+
+ AtomicInteger counter = viewCounters.computeIfAbsent(viewClass,
+ k -> {
+ AtomicInteger c = new AtomicInteger(0);
+ if (micrometerAvailable) {
+ registerViewGauge(k, c);
+ }
+ return c;
+ });
+ counter.incrementAndGet();
+
+ currentView.addDetachListener(event -> counter.decrementAndGet());
+ });
+
+ ui.addDetachListener(event -> activeUis.remove(ui));
+ }
+
+ int getActiveUiCount() {
+ return activeUis.size();
+ }
+
+ private static boolean isMicrometerAvailable() {
+ try {
+ Class.forName("io.micrometer.core.instrument.Metrics");
+ return true;
+ } catch (ClassNotFoundException e) {
+ return false;
+ }
+ }
+
+ private void registerTotalGauge() {
+ try {
+ io.micrometer.core.instrument.Gauge
+ .builder("vaadin.view.count", activeUis::size)
+ .description("Number of active Vaadin UI instances")
+ .register(
+ io.micrometer.core.instrument.Metrics.globalRegistry);
+ } catch (Exception e) {
+ log.debug("Failed to register total UI gauge: " + e.getMessage());
+ }
+ }
+
+ private void registerViewGauge(Class extends Component> viewClass,
+ AtomicInteger counter) {
+ try {
+ io.micrometer.core.instrument.Gauge
+ .builder("vaadin.view.count", counter::get)
+ .tag("view", viewClass.getSimpleName())
+ .description("Number of active " + viewClass.getSimpleName()
+ + " view instances")
+ .register(
+ io.micrometer.core.instrument.Metrics.globalRegistry);
+ } catch (Exception e) {
+ log.debug("Failed to register view gauge for "
+ + viewClass.getSimpleName() + ": " + e.getMessage());
+ }
+ }
+}
diff --git a/vaadin-testbench-loadtest/loadtest-helper/src/main/java/com/vaadin/testbench/loadtest/LoadTestServiceInitListener.java b/vaadin-testbench-loadtest/loadtest-helper/src/main/java/com/vaadin/testbench/loadtest/LoadTestServiceInitListener.java
new file mode 100644
index 000000000..5e1fca9f7
--- /dev/null
+++ b/vaadin-testbench-loadtest/loadtest-helper/src/main/java/com/vaadin/testbench/loadtest/LoadTestServiceInitListener.java
@@ -0,0 +1,47 @@
+/**
+ * Copyright (C) 2000-2026 Vaadin Ltd
+ *
+ * This program is available under Vaadin Commercial License and Service Terms.
+ *
+ * See for the full
+ * license.
+ */
+package com.vaadin.testbench.loadtest;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.vaadin.flow.server.ServiceInitEvent;
+import com.vaadin.flow.server.VaadinServiceInitListener;
+
+/**
+ * Registers the {@link LoadTestErrorHandler} on every new Vaadin session.
+ *
+ * This replaces the default error handler so that server-side exceptions (e.g.,
+ * {@code ObjectOptimisticLockingFailureException}) propagate as error responses
+ * instead of being silently swallowed, making them visible to k6 load test
+ * checks.
+ *
+ * Auto-registered via
+ * {@code META-INF/services/com.vaadin.flow.server.VaadinServiceInitListener}.
+ * Just add {@code loadtest-helper} as a dependency — no code changes needed.
+ */
+public class LoadTestServiceInitListener implements VaadinServiceInitListener {
+
+ private static final Logger log = LoggerFactory
+ .getLogger(LoadTestServiceInitListener.class);
+
+ private final LoadTestMetrics metrics = new LoadTestMetrics();
+
+ @Override
+ public void serviceInit(ServiceInitEvent event) {
+ event.getSource().addSessionInitListener(sessionEvent -> {
+ sessionEvent.getSession()
+ .setErrorHandler(new LoadTestErrorHandler());
+ log.debug("LoadTestErrorHandler registered for session");
+ });
+
+ event.getSource()
+ .addUIInitListener(uiEvent -> metrics.trackUI(uiEvent.getUI()));
+ }
+}
diff --git a/vaadin-testbench-loadtest/loadtest-helper/src/main/resources/META-INF/services/com.vaadin.flow.server.VaadinServiceInitListener b/vaadin-testbench-loadtest/loadtest-helper/src/main/resources/META-INF/services/com.vaadin.flow.server.VaadinServiceInitListener
new file mode 100644
index 000000000..631895a7c
--- /dev/null
+++ b/vaadin-testbench-loadtest/loadtest-helper/src/main/resources/META-INF/services/com.vaadin.flow.server.VaadinServiceInitListener
@@ -0,0 +1 @@
+com.vaadin.testbench.loadtest.LoadTestServiceInitListener
diff --git a/vaadin-testbench-loadtest/pom.xml b/vaadin-testbench-loadtest/pom.xml
new file mode 100644
index 000000000..13e7813e5
--- /dev/null
+++ b/vaadin-testbench-loadtest/pom.xml
@@ -0,0 +1,70 @@
+
+
+ 4.0.0
+
+
+ com.vaadin
+ vaadin-testbench-parent
+ 10.2-SNAPSHOT
+
+
+ com.vaadin
+ vaadin-testbench-loadtest
+ Vaadin TestBench Load Test
+ pom
+
+ Tools for converting Vaadin TestBench tests to k6 load tests
+
+
+ testbench-loadtest-support
+ testbench-converter-plugin
+ loadtest-helper
+ load-tests
+
+
+
+ UTF-8
+
+
+
+
+ vaadin-prereleases
+ https://maven.vaadin.com/vaadin-prereleases
+
+ false
+
+
+
+ Vaadin Directory
+ https://maven.vaadin.com/vaadin-addons
+
+ false
+
+
+
+
+
+
+ vaadin-prereleases
+ https://maven.vaadin.com/vaadin-prereleases
+
+ false
+
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.14.0
+
+
+
+
+
+
diff --git a/vaadin-testbench-loadtest/testbench-converter-plugin/README.md b/vaadin-testbench-loadtest/testbench-converter-plugin/README.md
new file mode 100644
index 000000000..682c0eaee
--- /dev/null
+++ b/vaadin-testbench-loadtest/testbench-converter-plugin/README.md
@@ -0,0 +1,144 @@
+# TestBench k6 Converter Maven Plugin
+
+A Maven plugin that converts Vaadin TestBench tests to k6 load tests. It automates the process of recording HTTP traffic through a proxy, converting HAR files to k6 scripts, and refactoring them for Vaadin-specific session handling.
+
+## Prerequisites
+
+- **Java 21+** - For running Maven and the plugin
+- **k6** - For running load tests (only needed for `k6:run` goal)
+
+## Installation
+
+The plugin is built as part of the parent project:
+
+```bash
+mvn install -pl testbench-converter-plugin
+```
+
+## Goals
+
+| Goal | Description |
+|------|-------------|
+| `k6:convert` | Convert a HAR file to a k6 load test |
+| `k6:record` | Record a TestBench test and convert to k6 |
+| `k6:run` | Run a k6 load test |
+| `k6:help` | Display help information |
+
+## Usage
+
+### k6:convert - Convert HAR to k6
+
+Converts an existing HAR file to a Vaadin-compatible k6 test.
+
+```bash
+mvn k6:convert -Dk6.harFile=recording.har
+```
+
+**Parameters:**
+
+| Parameter | Default | Description |
+|-----------|---------|-------------|
+| `k6.harFile` | (required) | Path to the HAR file to convert |
+| `k6.outputDir` | `${project.build.directory}/k6/tests` | Output directory for k6 tests |
+| `k6.outputName` | (derived from HAR file) | Output file base name |
+| `k6.skipFilter` | `false` | Skip filtering external domains |
+| `k6.skipRefactor` | `false` | Skip Vaadin-specific refactoring |
+
+### k6:record - Record TestBench Test
+
+Records a TestBench test through a proxy and converts it to k6.
+
+```bash
+mvn k6:record -Dk6.testClass=HelloWorldIT -Dk6.appPort=8080
+```
+
+**Parameters:**
+
+| Parameter | Default | Description |
+|-----------|---------|-------------|
+| `k6.testClass` | (required) | TestBench test class to record |
+| `k6.proxyPort` | `6000` | Port for the recording proxy |
+| `k6.appPort` | `8080` | Port where the application is running |
+| `k6.testWorkDir` | `${project.basedir}` | Working directory for Maven test execution |
+| `k6.harDir` | `${project.build.directory}` | Directory for HAR recordings |
+| `k6.outputDir` | `${project.build.directory}/k6/tests` | Output directory for k6 tests |
+| `k6.testTimeout` | `300` | Timeout for test execution (seconds) |
+| `k6.thinkTime.enabled` | `true` | Enable realistic think time delays |
+| `k6.thinkTime.pageReadDelay` | `2.0` | Base delay (seconds) after page load |
+| `k6.thinkTime.interactionDelay` | `0.5` | Base delay (seconds) after user interaction |
+
+### k6:run - Run k6 Load Test
+
+Executes a k6 test file with configurable parameters.
+
+```bash
+mvn k6:run -Dk6.testFile=target/k6/tests/hello-world.js -Dk6.vus=50 -Dk6.duration=1m
+```
+
+**Parameters:**
+
+| Parameter | Default | Description |
+|-----------|---------|-------------|
+| `k6.testFile` | (required) | Path to the k6 test file |
+| `k6.vus` | `10` | Number of virtual users |
+| `k6.duration` | `30s` | Test duration (e.g., "30s", "1m", "5m") |
+| `k6.appIp` | `localhost` | Application IP address |
+| `k6.appPort` | `8080` | Application port |
+| `k6.failOnThreshold` | `true` | Fail build if k6 thresholds are breached |
+
+## POM Configuration
+
+Add the plugin to your project:
+
+```xml
+
+ com.vaadin
+ testbench-converter-plugin
+ 10.2-SNAPSHOT
+
+
+ record-scenario
+ integration-test
+
+ record
+
+
+ HelloWorldIT
+ 8081
+
+
+
+ run-load-test
+ integration-test
+
+ run
+
+
+ ${project.build.directory}/k6/tests/hello-world.js
+ 50
+ 1m
+
+
+
+
+```
+
+## How It Works
+
+The plugin uses pure Java utilities (no Node.js required):
+
+1. **Proxy Recording** (`ProxyRecorder.java`) - BrowserMob Proxy-based MITM proxy that captures traffic as HAR
+2. **HAR Filtering** (`HarFilter.java`) - Removes external domain requests (Google, analytics, etc.)
+3. **HAR to k6** (`HarToK6Converter.java`) - Converts HAR to k6 script
+4. **Vaadin Refactoring** (`K6TestRefactorer.java`) - Adds dynamic session handling:
+ - Extracts JSESSIONID from responses
+ - Extracts CSRF token, UI ID, and Push ID
+ - Replaces hardcoded IPs with configurable variables
+
+## Generated Test Features
+
+The refactored k6 tests include:
+
+- **Dynamic session handling** - Extracts session IDs from responses
+- **Configurable target** - Use `-e APP_IP=host -e APP_PORT=port` to test different servers
+- **Vaadin helper imports** - Uses shared utility functions for Vaadin protocol handling (`vaadin-k6-helpers.js`)
diff --git a/vaadin-testbench-loadtest/testbench-converter-plugin/pom.xml b/vaadin-testbench-loadtest/testbench-converter-plugin/pom.xml
new file mode 100644
index 000000000..1095076dd
--- /dev/null
+++ b/vaadin-testbench-loadtest/testbench-converter-plugin/pom.xml
@@ -0,0 +1,148 @@
+
+
+ 4.0.0
+
+
+ com.vaadin
+ vaadin-testbench-loadtest
+ 10.2-SNAPSHOT
+
+
+ testbench-converter-plugin
+ maven-plugin
+ TestBench Converter Maven Plugin
+ Maven plugin for converting Vaadin TestBench tests to k6 load tests
+
+
+
+ 21
+ 3.9.6
+ 3.9.6
+ 3.11.0
+
+
+
+
+
+ org.apache.maven
+ maven-plugin-api
+ ${maven-plugin-api.version}
+ provided
+
+
+ org.apache.maven.plugin-tools
+ maven-plugin-annotations
+ ${maven-plugin-annotations.version}
+ provided
+
+
+ org.apache.maven
+ maven-core
+ ${maven.version}
+ provided
+
+
+ org.apache.maven
+ maven-model
+ ${maven.version}
+ provided
+
+
+
+
+ tools.jackson.core
+ jackson-databind
+ 3.1.0
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ 2.21.2
+
+
+
+
+ com.vaadin
+ license-checker
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ 5.11.4
+ test
+
+
+ org.mockito
+ mockito-core
+ 5.15.2
+ test
+
+
+ org.mockito
+ mockito-junit-jupiter
+ 5.15.2
+ test
+
+
+
+
+ net.lightbody.bmp
+ browsermob-core
+ 2.1.5
+
+
+ com.fasterxml.jackson.core
+ jackson-core
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
+
+ com.fasterxml.jackson.core
+ jackson-annotations
+
+
+
+
+
+
+
+
+ src/main/resources
+ true
+
+
+
+
+ org.apache.maven.plugins
+ maven-plugin-plugin
+ ${maven-plugin-annotations.version}
+
+ k6
+
+
+
+ default-descriptor
+ process-classes
+
+
+ help-goal
+
+ helpmojo
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+
+
+
diff --git a/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/AbstractK6Mojo.java b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/AbstractK6Mojo.java
new file mode 100644
index 000000000..036b728f4
--- /dev/null
+++ b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/AbstractK6Mojo.java
@@ -0,0 +1,213 @@
+/**
+ * Copyright (C) 2000-2026 Vaadin Ltd
+ *
+ * This program is available under Vaadin Commercial License and Service Terms.
+ *
+ * See for the full
+ * license.
+ */
+package com.vaadin.testbench.loadtest;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Properties;
+
+import org.apache.maven.plugin.AbstractMojo;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugins.annotations.Parameter;
+import org.apache.maven.project.MavenProject;
+
+import com.vaadin.pro.licensechecker.Capabilities;
+import com.vaadin.pro.licensechecker.Capability;
+import com.vaadin.pro.licensechecker.LicenseChecker;
+import com.vaadin.testbench.loadtest.util.NodeRunner;
+import com.vaadin.testbench.loadtest.util.ResourceExtractor;
+import com.vaadin.testbench.loadtest.util.ThresholdConfig;
+
+/**
+ * Base class for k6-related Maven goals. Provides common functionality for
+ * resource extraction and utility management.
+ *
+ * Note: Node.js and npm are no longer required. All HAR processing and k6
+ * conversion is now handled by pure Java implementations.
+ */
+public abstract class AbstractK6Mojo extends AbstractMojo {
+
+ /**
+ * The Maven project.
+ */
+ @Parameter(defaultValue = "${project}", readonly = true, required = true)
+ protected MavenProject project;
+
+ /**
+ * Directory to extract k6 utilities to. Defaults to target/k6-utils.
+ */
+ @Parameter(property = "k6.utilsDir", defaultValue = "${project.build.directory}/k6-utils")
+ protected String utilsDir;
+
+ /**
+ * Skip execution of this goal.
+ */
+ @Parameter(property = "k6.skip", defaultValue = "false")
+ protected boolean skip;
+
+ /**
+ * 95th percentile HTTP request duration threshold in milliseconds. The k6
+ * test will fail if p(95) response time exceeds this value. Set to 0 to
+ * disable this threshold.
+ */
+ @Parameter(property = "k6.threshold.httpReqDurationP95", defaultValue = "2000")
+ protected int httpReqDurationP95;
+
+ /**
+ * 99th percentile HTTP request duration threshold in milliseconds. The k6
+ * test will fail if p(99) response time exceeds this value. Set to 0 to
+ * disable this threshold.
+ */
+ @Parameter(property = "k6.threshold.httpReqDurationP99", defaultValue = "5000")
+ protected int httpReqDurationP99;
+
+ /**
+ * Whether to abort the k6 test immediately when a check fails. When true, a
+ * single failed check causes the test to stop. When false, failures are
+ * still recorded but the test continues.
+ */
+ @Parameter(property = "k6.threshold.checksAbortOnFail", defaultValue = "true")
+ protected boolean checksAbortOnFail;
+
+ protected ResourceExtractor resourceExtractor;
+ protected NodeRunner nodeRunner;
+ protected Path extractionPath;
+
+ /**
+ * Initializes the plugin by extracting utilities. Node.js and npm are no
+ * longer required as all processing is done in Java.
+ *
+ * @throws MojoExecutionException
+ * if initialization fails
+ */
+ protected void initialize() throws MojoExecutionException {
+ checkLicense();
+
+ extractionPath = Path.of(utilsDir);
+
+ // Extract bundled utilities (only vaadin-k6-helpers.js is needed for k6
+ // runtime)
+ resourceExtractor = new ResourceExtractor(extractionPath);
+ try {
+ resourceExtractor.extractUtilities();
+ getLog().debug("Extracted k6 utilities to: " + extractionPath);
+ } catch (IOException e) {
+ throw new MojoExecutionException("Failed to extract k6 utilities",
+ e);
+ }
+
+ // Initialize runner (now uses Java implementations internally)
+ nodeRunner = new NodeRunner(extractionPath);
+ }
+
+ // Visible for testing
+ void checkLicense() throws MojoExecutionException {
+ Properties properties = new Properties();
+ try {
+ properties.load(AbstractK6Mojo.class
+ .getResourceAsStream("testbench.properties"));
+ } catch (Exception e) {
+ throw new MojoExecutionException(
+ "Unable to read TestBench properties file", e);
+ }
+ LicenseChecker.checkLicenseFromStaticBlock("vaadin-testbench",
+ properties.getProperty("testbench.version"), null,
+ Capabilities.of(Capability.PRE_TRIAL));
+ }
+
+ /**
+ * Ensures the output directory exists.
+ *
+ * @param outputDir
+ * the output directory path
+ * @throws MojoExecutionException
+ * if the directory cannot be created
+ */
+ protected void ensureDirectoryExists(Path outputDir)
+ throws MojoExecutionException {
+ try {
+ Files.createDirectories(outputDir);
+ } catch (IOException e) {
+ throw new MojoExecutionException(
+ "Failed to create output directory: " + outputDir, e);
+ }
+ }
+
+ /**
+ * Copies the Vaadin k6 helpers to the utils directory next to the output.
+ * This ensures the generated k6 tests can import the helpers.
+ *
+ * @param outputDir
+ * the output directory for k6 tests
+ * @throws MojoExecutionException
+ * if the copy fails
+ */
+ protected void copyVaadinHelpers(Path outputDir)
+ throws MojoExecutionException {
+ try {
+ Path utilsOutputDir = outputDir.resolve("../utils");
+ Files.createDirectories(utilsOutputDir);
+
+ Path source = resourceExtractor.getVaadinHelpersScript();
+ Path target = utilsOutputDir.resolve("vaadin-k6-helpers.js");
+
+ if (!Files.exists(target) || Files.getLastModifiedTime(source)
+ .compareTo(Files.getLastModifiedTime(target)) > 0) {
+ Files.copy(source, target,
+ java.nio.file.StandardCopyOption.REPLACE_EXISTING);
+ getLog().info(
+ "Copied vaadin-k6-helpers.js to " + utilsOutputDir);
+ }
+ } catch (IOException e) {
+ throw new MojoExecutionException("Failed to copy Vaadin helpers",
+ e);
+ }
+ }
+
+ /**
+ * Builds a {@link ThresholdConfig} from the Maven parameters.
+ *
+ * @return the threshold configuration
+ */
+ protected ThresholdConfig buildThresholdConfig() {
+ return new ThresholdConfig(httpReqDurationP95, httpReqDurationP99,
+ checksAbortOnFail);
+ }
+
+ /**
+ * Converts a scenario class name to a kebab-case output file name. E.g.,
+ * "HelloWorldScenario" -> "hello-world"
+ *
+ * @param scenarioClass
+ * the scenario class name
+ * @return the kebab-case name
+ */
+ protected String scenarioToFileName(String scenarioClass) {
+ // Remove common suffixes
+ String name = scenarioClass.replaceAll("ScenarioIT$", "")
+ .replaceAll("Scenario$", "").replaceAll("IT$", "")
+ .replaceAll("Test$", "");
+
+ // Convert CamelCase to kebab-case
+ StringBuilder result = new StringBuilder();
+ for (int i = 0; i < name.length(); i++) {
+ char c = name.charAt(i);
+ if (Character.isUpperCase(c)) {
+ if (i > 0) {
+ result.append('-');
+ }
+ result.append(Character.toLowerCase(c));
+ } else {
+ result.append(c);
+ }
+ }
+ return result.toString();
+ }
+}
diff --git a/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/K6ConvertMojo.java b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/K6ConvertMojo.java
new file mode 100644
index 000000000..4d1785945
--- /dev/null
+++ b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/K6ConvertMojo.java
@@ -0,0 +1,144 @@
+/**
+ * Copyright (C) 2000-2026 Vaadin Ltd
+ *
+ * This program is available under Vaadin Commercial License and Service Terms.
+ *
+ * See for the full
+ * license.
+ */
+package com.vaadin.testbench.loadtest;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.MojoFailureException;
+import org.apache.maven.plugins.annotations.Mojo;
+import org.apache.maven.plugins.annotations.Parameter;
+
+/**
+ * Converts a HAR file to a k6 load test script.
+ *
+ * This goal performs three steps:
+ *
+ * - Filter external domains from the HAR file (optional)
+ * - Convert HAR to k6 using har-to-k6
+ * - Refactor the generated script for Vaadin compatibility
+ *
+ *
+ * Example usage:
+ *
+ *
+ * mvn k6:convert -Dk6.harFile=recording.har
+ *
+ */
+@Mojo(name = "convert", requiresProject = false)
+public class K6ConvertMojo extends AbstractK6Mojo {
+
+ /**
+ * The HAR file to convert.
+ */
+ @Parameter(property = "k6.harFile", required = true)
+ private File harFile;
+
+ /**
+ * Output directory for generated k6 tests. Defaults to k6/tests within the
+ * target directory.
+ */
+ @Parameter(property = "k6.outputDir", defaultValue = "${project.build.directory}/k6/tests")
+ private File outputDir;
+
+ /**
+ * Output file name for the generated test. If not specified, derives from
+ * HAR file name.
+ */
+ @Parameter(property = "k6.outputName")
+ private String outputName;
+
+ /**
+ * Skip filtering of external domains from the HAR file.
+ */
+ @Parameter(property = "k6.skipFilter", defaultValue = "false")
+ private boolean skipFilter;
+
+ /**
+ * Skip the Vaadin-specific refactoring step.
+ */
+ @Parameter(property = "k6.skipRefactor", defaultValue = "false")
+ private boolean skipRefactor;
+
+ @Override
+ public void execute() throws MojoExecutionException, MojoFailureException {
+ if (skip) {
+ getLog().info("Skipping k6:convert");
+ return;
+ }
+
+ // Validate input
+ Path harPath = harFile.toPath().toAbsolutePath();
+ if (!Files.exists(harPath)) {
+ throw new MojoExecutionException("HAR file not found: " + harPath);
+ }
+
+ getLog().info("Converting HAR file: " + harPath);
+
+ // Initialize (extract utilities, validate prerequisites)
+ initialize();
+
+ // Prepare output
+ Path outputPath = outputDir.toPath().toAbsolutePath();
+ ensureDirectoryExists(outputPath);
+
+ // Determine output file name
+ String baseName = outputName;
+ if (baseName == null || baseName.isEmpty()) {
+ String harFileName = harFile.getName();
+ baseName = harFileName.replaceAll("-recording\\.har$", "")
+ .replaceAll("\\.har$", "");
+ }
+
+ Path generatedFile = outputPath.resolve(baseName + "-generated.js");
+ Path refactoredFile = outputPath.resolve(baseName + ".js");
+
+ try {
+ // Step 1: Filter external domains (optional)
+ if (!skipFilter) {
+ nodeRunner.filterHar(harPath);
+ } else {
+ getLog().info("Skipping HAR filtering");
+ }
+
+ // Step 2: Convert HAR to k6
+ nodeRunner.harToK6(harPath, generatedFile);
+
+ // Step 3: Refactor for Vaadin (optional)
+ if (!skipRefactor) {
+ nodeRunner.refactorK6Test(generatedFile, refactoredFile);
+
+ // Copy Vaadin helpers
+ copyVaadinHelpers(outputPath);
+
+ getLog().info("");
+ getLog().info("Conversion complete!");
+ getLog().info(" Generated test: " + refactoredFile);
+ getLog().info("");
+ getLog().info("Run the test with:");
+ getLog().info(" k6 run " + refactoredFile);
+ getLog().info("");
+ getLog().info("Or with custom server:");
+ getLog().info(
+ " k6 run -e APP_IP=192.168.1.100 -e APP_PORT=8080 "
+ + refactoredFile);
+ } else {
+ getLog().info("Skipping Vaadin refactoring");
+ getLog().info("");
+ getLog().info("Conversion complete!");
+ getLog().info(" Generated test: " + generatedFile);
+ }
+
+ } catch (Exception e) {
+ throw new MojoExecutionException("Conversion failed", e);
+ }
+ }
+}
diff --git a/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/K6RecordMojo.java b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/K6RecordMojo.java
new file mode 100644
index 000000000..5d56c4712
--- /dev/null
+++ b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/K6RecordMojo.java
@@ -0,0 +1,445 @@
+/**
+ * Copyright (C) 2000-2026 Vaadin Ltd
+ *
+ * This program is available under Vaadin Commercial License and Service Terms.
+ *
+ * See for the full
+ * license.
+ */
+package com.vaadin.testbench.loadtest;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.InputStreamReader;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.MojoFailureException;
+import org.apache.maven.plugins.annotations.LifecyclePhase;
+import org.apache.maven.plugins.annotations.Mojo;
+import org.apache.maven.plugins.annotations.Parameter;
+
+import com.vaadin.testbench.loadtest.util.K6TestRefactorer.ThinkTimeConfig;
+import com.vaadin.testbench.loadtest.util.SourceHasher;
+
+/**
+ * Records TestBench tests through a proxy and converts them to k6 load tests.
+ *
+ * This goal supports recording multiple test classes. For each test class it:
+ *
+ * - Starts a recording proxy on the specified port
+ * - Runs the TestBench test class with proxy configuration
+ * - Stops the proxy and saves the HAR file
+ * - Converts the HAR to a k6 test (filters, converts, refactors)
+ *
+ *
+ * Example usage:
+ *
+ *
+ * mvn k6:record -Dk6.testClasses=HelloWorldIT,CrudExampleIT
+ *
+ */
+@Mojo(name = "record", defaultPhase = LifecyclePhase.INTEGRATION_TEST)
+public class K6RecordMojo extends AbstractK6Mojo {
+
+ /**
+ * The TestBench test class to record. Can be a simple class name or fully
+ * qualified name. For multiple classes, use testClasses instead.
+ */
+ @Parameter(property = "k6.testClass")
+ private String testClass;
+
+ /**
+ * List of TestBench test classes to record. Each class will be recorded
+ * separately and generate its own k6 test file.
+ */
+ @Parameter(property = "k6.testClasses")
+ private List testClasses;
+
+ /**
+ * Port for the recording proxy.
+ */
+ @Parameter(property = "k6.proxyPort", defaultValue = "6000")
+ private int proxyPort;
+
+ /**
+ * Port where the application is running.
+ */
+ @Parameter(property = "k6.appPort", defaultValue = "8080")
+ private int appPort;
+
+ /**
+ * Working directory for running the TestBench test. Defaults to the project
+ * base directory.
+ */
+ @Parameter(property = "k6.testWorkDir", defaultValue = "${project.basedir}")
+ private File testWorkDir;
+
+ /**
+ * Directory to store HAR recordings.
+ */
+ @Parameter(property = "k6.harDir", defaultValue = "${project.build.directory}")
+ private File harDir;
+
+ /**
+ * Output directory for generated k6 tests.
+ */
+ @Parameter(property = "k6.outputDir", defaultValue = "${project.build.directory}/k6/tests")
+ private File outputDir;
+
+ /**
+ * Timeout for TestBench test execution in seconds.
+ */
+ @Parameter(property = "k6.testTimeout", defaultValue = "300")
+ private int testTimeout;
+
+ /**
+ * Additional Maven arguments for running the TestBench test.
+ */
+ @Parameter(property = "k6.mavenArgs")
+ private String mavenArgs;
+
+ /**
+ * Force re-recording even if sources haven't changed. By default, recording
+ * is skipped if the test source files and pom.xml haven't changed since the
+ * last recording.
+ */
+ @Parameter(property = "k6.forceRecord", defaultValue = "false")
+ private boolean forceRecord;
+
+ /**
+ * Enable realistic think time delays between user actions. When enabled,
+ * the generated k6 scripts will include sleep() calls to simulate real user
+ * behavior (reading pages, thinking before actions). Set to false for
+ * maximum throughput testing.
+ */
+ @Parameter(property = "k6.thinkTime.enabled", defaultValue = "true")
+ private boolean thinkTimeEnabled;
+
+ /**
+ * Base delay in seconds after page load (v-r=init response). Simulates time
+ * for a user to read and understand the page. Actual delay will be:
+ * baseDelay + random(0, baseDelay * 1.5) Set to 0 to disable page read
+ * delays while keeping interaction delays.
+ */
+ @Parameter(property = "k6.thinkTime.pageReadDelay", defaultValue = "2.0")
+ private double pageReadDelay;
+
+ /**
+ * Base delay in seconds after user interaction (v-r=uidl response).
+ * Simulates thinking time between user actions. Actual delay will be:
+ * baseDelay + random(0, baseDelay * 3) Set to 0 to disable interaction
+ * delays while keeping page read delays.
+ */
+ @Parameter(property = "k6.thinkTime.interactionDelay", defaultValue = "0.5")
+ private double interactionDelay;
+
+ private SourceHasher sourceHasher;
+
+ @Override
+ public void execute() throws MojoExecutionException, MojoFailureException {
+ if (skip) {
+ getLog().info("Skipping k6:record");
+ return;
+ }
+
+ // Build list of test classes to record
+ List classesToRecord = getTestClassesToRecord();
+ if (classesToRecord.isEmpty()) {
+ throw new MojoExecutionException(
+ "No test classes specified. Use testClass or testClasses parameter.");
+ }
+
+ getLog().info(
+ "Recording " + classesToRecord.size() + " TestBench test(s)");
+ getLog().info(" Proxy port: " + proxyPort);
+ getLog().info(" App port: " + appPort);
+
+ // Initialize (extract utilities, validate prerequisites)
+ initialize();
+ sourceHasher = new SourceHasher();
+
+ Path outputPath = outputDir.toPath().toAbsolutePath();
+ ensureDirectoryExists(outputPath);
+
+ List generatedTests = new ArrayList<>();
+ List cachedTests = new ArrayList<>();
+
+ // Record each test class
+ for (String currentTestClass : classesToRecord) {
+ try {
+ RecordResult result = recordSingleTest(currentTestClass,
+ outputPath);
+ if (result.wasCached()) {
+ cachedTests.add(result.testFile());
+ } else {
+ generatedTests.add(result.testFile());
+ }
+ } catch (Exception e) {
+ getLog().error(
+ "Failed to record test class: " + currentTestClass, e);
+ throw new MojoExecutionException(
+ "Failed to record test class: " + currentTestClass, e);
+ }
+ }
+
+ // Copy Vaadin helpers once
+ copyVaadinHelpers(outputPath);
+
+ getLog().info("");
+ if (!generatedTests.isEmpty()) {
+ getLog().info("Recorded " + generatedTests.size() + " test(s):");
+ for (Path test : generatedTests) {
+ getLog().info(" - " + test.getFileName());
+ }
+ }
+ if (!cachedTests.isEmpty()) {
+ getLog().info(
+ "Skipped " + cachedTests.size() + " unchanged test(s):");
+ for (Path test : cachedTests) {
+ getLog().info(" - " + test.getFileName() + " (cached)");
+ }
+ }
+ getLog().info("");
+ getLog().info("Run the tests with:");
+ for (Path test : generatedTests) {
+ getLog().info(" k6 run -e APP_PORT=" + appPort + " " + test);
+ }
+ for (Path test : cachedTests) {
+ getLog().info(" k6 run -e APP_PORT=" + appPort + " " + test);
+ }
+ }
+
+ /**
+ * Result of recording a single test.
+ */
+ private record RecordResult(Path testFile, boolean wasCached) {
+ }
+
+ /**
+ * Builds the list of test classes to record from configuration.
+ */
+ private List getTestClassesToRecord() {
+ List result = new ArrayList<>();
+
+ // Add from testClasses list
+ if (testClasses != null && !testClasses.isEmpty()) {
+ for (String tc : testClasses) {
+ // Support comma-separated values in list items
+ if (tc.contains(",")) {
+ result.addAll(Arrays.asList(tc.split("\\s*,\\s*")));
+ } else {
+ result.add(tc.trim());
+ }
+ }
+ }
+
+ // Add single testClass if specified and not already in list
+ if (testClass != null && !testClass.isEmpty()) {
+ // Support comma-separated values
+ for (String tc : testClass.split("\\s*,\\s*")) {
+ if (!result.contains(tc.trim())) {
+ result.add(tc.trim());
+ }
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * Records a single test class and returns the path to the generated k6
+ * test. Uses hash-based caching to skip recording if sources haven't
+ * changed.
+ */
+ private RecordResult recordSingleTest(String currentTestClass,
+ Path outputPath) throws MojoExecutionException,
+ InterruptedException, java.io.IOException {
+
+ // Prepare paths
+ String outputName = scenarioToFileName(currentTestClass);
+ Path harPath = harDir.toPath().resolve(outputName + "-recording.har")
+ .toAbsolutePath();
+ Path generatedFile = outputPath.resolve(outputName + "-generated.js");
+ Path refactoredFile = outputPath.resolve(outputName + ".js");
+ Path hashFile = outputPath.resolve(outputName + ".hash");
+
+ // Check if we can use cached version
+ if (!forceRecord && Files.exists(refactoredFile)) {
+ String currentHash = sourceHasher.calculateSourceHash(
+ testWorkDir.toPath(), currentTestClass);
+ String storedHash = sourceHasher.readStoredHash(hashFile);
+
+ if (currentHash != null && currentHash.equals(storedHash)) {
+ getLog().info("");
+ getLog().info("========================================");
+ getLog().info("Skipping: " + currentTestClass + " (unchanged)");
+ getLog().info("========================================");
+ return new RecordResult(refactoredFile, true);
+ }
+ }
+
+ getLog().info("");
+ getLog().info("========================================");
+ getLog().info("Recording: " + currentTestClass);
+ getLog().info("========================================");
+
+ ensureDirectoryExists(harPath.getParent());
+
+ // Clean up old files
+ try {
+ Files.deleteIfExists(harPath);
+ Files.deleteIfExists(generatedFile);
+ } catch (Exception e) {
+ getLog().warn("Could not clean up old files: " + e.getMessage());
+ }
+
+ try {
+ // Step 1: Start proxy recorder
+ nodeRunner.startProxyRecorder(proxyPort, harPath);
+
+ // Step 2: Run TestBench test
+ boolean testSuccess = runTestBenchTest(currentTestClass);
+
+ // Step 3: Verify proxy captured traffic before stopping
+ int recordedEntries = nodeRunner.getRecordedEntryCount();
+ if (recordedEntries == 0) {
+ nodeRunner.stopProxyRecorder();
+ throw new MojoExecutionException(
+ "Proxy recorded 0 requests for test '"
+ + currentTestClass + "'. "
+ + "The test likely did not route traffic through the proxy on port "
+ + proxyPort + ". "
+ + "Verify that the K6RecordingExtension is on the classpath and "
+ + "junit.jupiter.extensions.autodetection.enabled=true is set.");
+ }
+ getLog().info("Proxy captured " + recordedEntries + " requests");
+
+ // Step 4: Stop proxy (this writes the HAR file)
+ nodeRunner.stopProxyRecorder();
+
+ // Wait a moment for HAR file to be written
+ Thread.sleep(1000);
+
+ // Check HAR file
+ if (!Files.exists(harPath)) {
+ throw new MojoExecutionException(
+ "HAR file was not created: " + harPath);
+ }
+
+ getLog().info("HAR file created: " + harPath + " ("
+ + Files.size(harPath) + " bytes)");
+
+ if (!testSuccess) {
+ getLog().warn(
+ "TestBench test may have failed, but HAR was recorded. Continuing with conversion...");
+ }
+
+ // Step 4: Filter external domains
+ nodeRunner.filterHar(harPath);
+
+ // Step 5: Convert HAR to k6 (with configurable thresholds)
+ nodeRunner.harToK6(harPath, generatedFile, buildThresholdConfig());
+
+ // Step 6: Refactor for Vaadin (with think time configuration)
+ ThinkTimeConfig thinkTimeConfig = new ThinkTimeConfig(
+ thinkTimeEnabled, pageReadDelay, interactionDelay);
+ nodeRunner.refactorK6Test(generatedFile, refactoredFile,
+ thinkTimeConfig);
+
+ // Step 7: Store hash for future cache checks
+ String currentHash = sourceHasher.calculateSourceHash(
+ testWorkDir.toPath(), currentTestClass);
+ if (currentHash != null) {
+ sourceHasher.storeHash(hashFile, currentHash);
+ }
+
+ return new RecordResult(refactoredFile, false);
+
+ } finally {
+ // Ensure proxy is stopped
+ nodeRunner.stopProxyRecorder();
+ }
+ }
+
+ /**
+ * Runs the TestBench test using Maven failsafe plugin.
+ *
+ * @param currentTestClass
+ * the test class to run
+ * @return true if the test completed successfully
+ * @throws MojoExecutionException
+ * if test execution fails critically
+ */
+ private boolean runTestBenchTest(String currentTestClass)
+ throws MojoExecutionException {
+ getLog().info("Running TestBench test: " + currentTestClass);
+
+ try {
+ List command = new ArrayList<>();
+ boolean isWindows = System.getProperty("os.name", "").toLowerCase()
+ .contains("win");
+ if (isWindows) {
+ command.add("cmd.exe");
+ command.add("/c");
+ }
+ command.add("mvn");
+ command.add("failsafe:integration-test");
+ command.add("-Dit.test=" + currentTestClass);
+ command.add("-Dk6.proxy.host=localhost:" + proxyPort);
+ command.add("-Dserver.port=" + appPort);
+ command.add("-DfailIfNoTests=false");
+ // Enable JUnit 5 auto-detection for K6RecordingExtension
+ command.add(
+ "-Djunit.jupiter.extensions.autodetection.enabled=true");
+
+ // Add any extra Maven arguments
+ if (mavenArgs != null && !mavenArgs.isEmpty()) {
+ for (String arg : mavenArgs.split("\\s+")) {
+ command.add(arg);
+ }
+ }
+
+ getLog().debug("Test command: " + String.join(" ", command));
+
+ ProcessBuilder pb = new ProcessBuilder(command);
+ pb.directory(testWorkDir);
+ pb.redirectErrorStream(true);
+
+ Process process = pb.start();
+
+ // Stream output
+ try (BufferedReader reader = new BufferedReader(
+ new InputStreamReader(process.getInputStream()))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ getLog().info("[test] " + line);
+ }
+ }
+
+ boolean finished = process.waitFor(testTimeout, TimeUnit.SECONDS);
+ if (!finished) {
+ process.destroyForcibly();
+ getLog().warn("TestBench test timed out after " + testTimeout
+ + " seconds");
+ return false;
+ }
+
+ int exitCode = process.exitValue();
+ if (exitCode != 0) {
+ getLog().warn("TestBench test exited with code: " + exitCode);
+ return false;
+ }
+
+ return true;
+
+ } catch (Exception e) {
+ throw new MojoExecutionException("Failed to run TestBench test", e);
+ }
+ }
+}
diff --git a/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/K6RunMojo.java b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/K6RunMojo.java
new file mode 100644
index 000000000..a255a2017
--- /dev/null
+++ b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/K6RunMojo.java
@@ -0,0 +1,442 @@
+/**
+ * Copyright (C) 2000-2026 Vaadin Ltd
+ *
+ * This program is available under Vaadin Commercial License and Service Terms.
+ *
+ * See for the full
+ * license.
+ */
+package com.vaadin.testbench.loadtest;
+
+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.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.MojoFailureException;
+import org.apache.maven.plugins.annotations.LifecyclePhase;
+import org.apache.maven.plugins.annotations.Mojo;
+import org.apache.maven.plugins.annotations.Parameter;
+
+import com.vaadin.testbench.loadtest.util.ActuatorMetrics;
+import com.vaadin.testbench.loadtest.util.ActuatorMetrics.MetricsSummary;
+import com.vaadin.testbench.loadtest.util.K6ScenarioCombiner;
+import com.vaadin.testbench.loadtest.util.K6ScenarioCombiner.ScenarioConfig;
+import com.vaadin.testbench.loadtest.util.MetricsCollector;
+
+/**
+ * Runs k6 load tests.
+ *
+ * This goal executes one or more k6 test files with configurable virtual users,
+ * duration, and target application settings.
+ *
+ * By default, tests are run sequentially. Use {@code combineScenarios=true} to
+ * run all scenarios in parallel with weighted VU distribution.
+ *
+ * Example usage:
+ *
+ *
+ * // Run tests sequentially
+ * mvn k6:run -Dk6.testDir=k6/tests -Dk6.vus=50 -Dk6.duration=1m
+ *
+ * // Run tests in parallel with equal weights
+ * mvn k6:run -Dk6.testDir=k6/tests -Dk6.combineScenarios=true -Dk6.vus=50
+ *
+ * // Run tests in parallel with custom weights (70% hello-world, 30% crud-example)
+ * mvn k6:run -Dk6.testDir=k6/tests -Dk6.combineScenarios=true \
+ * -Dk6.scenarioWeights="helloWorld:70,crudExample:30"
+ *
+ */
+@Mojo(name = "run", defaultPhase = LifecyclePhase.INTEGRATION_TEST)
+public class K6RunMojo extends AbstractK6Mojo {
+
+ /**
+ * The k6 test file to run. For multiple files, use testFiles or testDir.
+ */
+ @Parameter(property = "k6.testFile")
+ private File testFile;
+
+ /**
+ * List of k6 test files to run. Each file is run sequentially.
+ */
+ @Parameter(property = "k6.testFiles")
+ private List testFiles;
+
+ /**
+ * Directory containing k6 test files. All .js files (excluding helpers)
+ * will be run.
+ */
+ @Parameter(property = "k6.testDir")
+ private File testDir;
+
+ /**
+ * Number of virtual users (concurrent users).
+ */
+ @Parameter(property = "k6.vus", defaultValue = "10")
+ private int virtualUsers;
+
+ /**
+ * Test duration (e.g., "30s", "1m", "5m").
+ */
+ @Parameter(property = "k6.duration", defaultValue = "30s")
+ private String duration;
+
+ /**
+ * Application IP address.
+ */
+ @Parameter(property = "k6.appIp", defaultValue = "localhost")
+ private String appIp;
+
+ /**
+ * Application port.
+ */
+ @Parameter(property = "k6.appPort", defaultValue = "8080")
+ private int appPort;
+
+ /**
+ * Spring Boot Actuator management port. If the target application has
+ * Spring Boot Actuator enabled, metrics (CPU usage, heap memory) will be
+ * fetched and reported after the load test. Set to -1 to disable metrics
+ * collection.
+ *
+ * @see ActuatorMetrics
+ */
+ @Parameter(property = "k6.managementPort", defaultValue = "8082")
+ private int managementPort;
+
+ /**
+ * Enable collection of Vaadin-specific metrics from custom VaadinActuator
+ * endpoints. This requires the target application to implement
+ * VaadinActuator or equivalent that exposes Vaadin view metrics via Spring
+ * Boot Actuator.
+ *
+ * @see ActuatorMetrics
+ */
+ @Parameter(property = "k6.collectVaadinMetrics", defaultValue = "false")
+ private boolean collectVaadinMetrics;
+
+ /**
+ * Interval in seconds for collecting server metrics during the load test.
+ * Metrics are collected periodically and displayed as a time-series table
+ * after the test completes. Set to 0 to collect only a single snapshot at
+ * the end.
+ */
+ @Parameter(property = "k6.metricsInterval", defaultValue = "10")
+ private int metricsInterval;
+
+ /**
+ * Fail the build if any k6 thresholds are breached.
+ */
+ @Parameter(property = "k6.failOnThreshold", defaultValue = "true")
+ private boolean failOnThreshold;
+
+ /**
+ * When true, combines all test files into a single k6 test with parallel
+ * scenarios. Each scenario runs with its own VU pool based on the
+ * configured weights. When false (default), tests are run sequentially.
+ */
+ @Parameter(property = "k6.combineScenarios", defaultValue = "false")
+ private boolean combineScenarios;
+
+ /**
+ * Scenario weights for combined execution. Format:
+ * "scenarioName:weight,scenarioName:weight" Scenario names are derived from
+ * file names (hello-world.js -> helloWorld). If not specified, scenarios
+ * are weighted equally. Example: "helloWorld:70,crudExample:30"
+ */
+ @Parameter(property = "k6.scenarioWeights")
+ private String scenarioWeights;
+
+ @Override
+ public void execute() throws MojoExecutionException, MojoFailureException {
+ if (skip) {
+ getLog().info("Skipping k6:run");
+ return;
+ }
+
+ // Build list of test files to run
+ List filesToRun = getTestFilesToRun();
+ if (filesToRun.isEmpty()) {
+ throw new MojoExecutionException(
+ "No test files specified. Use testFile, testFiles, or testDir parameter.");
+ }
+
+ // Initialize (extract utilities)
+ initialize();
+
+ // Validate k6 is available
+ if (!nodeRunner.isK6Available()) {
+ throw new MojoExecutionException(
+ "k6 is required but not found. Please install k6: https://grafana.com/docs/k6/latest/get-started/installation/");
+ }
+
+ // Start metrics collection if enabled
+ MetricsCollector metricsCollector = null;
+ if (managementPort >= 0 && metricsInterval > 0) {
+ ActuatorMetrics actuator = new ActuatorMetrics(appIp,
+ managementPort, collectVaadinMetrics);
+ metricsCollector = new MetricsCollector(actuator, metricsInterval);
+ // Collect baseline before k6 starts to capture pre-test server
+ // state
+ metricsCollector.collectBaseline();
+ metricsCollector.start();
+ }
+
+ try {
+ if (combineScenarios && filesToRun.size() > 1) {
+ runCombinedScenarios(filesToRun);
+ } else {
+ runSequentialTests(filesToRun);
+ }
+ } finally {
+ // Stop metrics collection and report
+ if (metricsCollector != null) {
+ metricsCollector.stop();
+ metricsCollector.printReport();
+ } else {
+ // Fall back to single snapshot if periodic collection is
+ // disabled
+ reportActuatorMetrics();
+ }
+ }
+ }
+
+ /**
+ * Fetches and reports server metrics from Spring Boot Actuator. Silently
+ * skips if actuator is not available or disabled.
+ */
+ private void reportActuatorMetrics() {
+ if (managementPort < 0) {
+ getLog().debug(
+ "Actuator metrics collection disabled (managementPort < 0)");
+ return;
+ }
+
+ ActuatorMetrics actuator = new ActuatorMetrics(appIp, managementPort,
+ collectVaadinMetrics);
+ Optional metrics = actuator.fetchMetrics();
+
+ if (metrics.isPresent()) {
+ getLog().info("");
+ getLog().info("========================================");
+ getLog().info(metrics.get().toString());
+ getLog().info("========================================");
+ } else {
+ getLog().debug("Actuator metrics not available at " + appIp + ":"
+ + managementPort);
+ }
+ }
+
+ /**
+ * Runs all scenarios in parallel using k6's scenario feature.
+ */
+ private void runCombinedScenarios(List testFiles)
+ throws MojoExecutionException {
+ getLog().info("Running " + testFiles.size()
+ + " scenarios in parallel (combined mode)");
+ getLog().info("========================================");
+ getLog().info(" Total virtual users: " + virtualUsers);
+ getLog().info(" Duration: " + duration);
+ getLog().info(" Target: http://" + appIp + ":" + appPort);
+
+ // Parse weights
+ Map weights = parseScenarioWeights();
+ List scenarios = new ArrayList<>();
+
+ for (Path testFile : testFiles) {
+ String scenarioName = fileToScenarioName(testFile);
+ int weight = weights.getOrDefault(scenarioName,
+ 100 / testFiles.size());
+ scenarios.add(new ScenarioConfig(scenarioName, testFile, weight));
+ getLog().info(" Scenario: " + scenarioName + " (weight: " + weight
+ + "%)");
+ }
+ getLog().info("========================================");
+ getLog().info("");
+
+ // Generate combined test file
+ Path combinedFile = testFiles.get(0).getParent()
+ .resolve("combined-scenarios.js");
+ try {
+ K6ScenarioCombiner combiner = new K6ScenarioCombiner();
+ combiner.combine(scenarios, combinedFile, virtualUsers, duration,
+ buildThresholdConfig());
+ getLog().info("Generated combined test: " + combinedFile);
+ } catch (IOException e) {
+ throw new MojoExecutionException(
+ "Failed to generate combined test file", e);
+ }
+
+ // Run the combined test (VUs and duration are embedded in the file,
+ // don't override)
+ try {
+ nodeRunner.runK6Test(combinedFile, virtualUsers, duration, appIp,
+ appPort, true);
+ getLog().info("");
+ getLog().info("========================================");
+ getLog().info("Combined scenario test completed successfully");
+ getLog().info("========================================");
+ } catch (MojoExecutionException e) {
+ if (failOnThreshold) {
+ throw e;
+ } else {
+ getLog().warn(
+ "Combined test failed but failOnThreshold is false");
+ }
+ }
+ }
+
+ /**
+ * Runs tests sequentially (original behavior).
+ */
+ private void runSequentialTests(List filesToRun)
+ throws MojoExecutionException {
+ getLog().info("Running " + filesToRun.size()
+ + " k6 load test(s) sequentially");
+ getLog().info("========================================");
+ getLog().info(" Virtual users: " + virtualUsers);
+ getLog().info(" Duration: " + duration);
+ getLog().info(" Target: http://" + appIp + ":" + appPort);
+ getLog().info(" Tests: ");
+ for (Path test : filesToRun) {
+ getLog().info(" - " + test.getFileName());
+ }
+ getLog().info("========================================");
+ getLog().info("");
+
+ int passed = 0;
+ int failed = 0;
+
+ for (Path testPath : filesToRun) {
+ getLog().info("");
+ getLog().info("----------------------------------------");
+ getLog().info("Running: " + testPath.getFileName());
+ getLog().info("----------------------------------------");
+
+ try {
+ nodeRunner.runK6Test(testPath, virtualUsers, duration, appIp,
+ appPort);
+ passed++;
+ getLog().info("Test passed: " + testPath.getFileName());
+ } catch (MojoExecutionException e) {
+ failed++;
+ if (failOnThreshold) {
+ getLog().error("Test failed: " + testPath.getFileName());
+ throw e;
+ } else {
+ getLog().warn("Test failed but failOnThreshold is false: "
+ + testPath.getFileName());
+ }
+ }
+ }
+
+ getLog().info("");
+ getLog().info("========================================");
+ getLog().info("k6 load test summary: " + passed + " passed, " + failed
+ + " failed");
+ getLog().info("========================================");
+ }
+
+ /**
+ * Parses scenario weights from the scenarioWeights parameter. Format:
+ * "scenarioName:weight,scenarioName:weight"
+ */
+ private Map parseScenarioWeights() {
+ Map weights = new HashMap<>();
+ if (scenarioWeights != null && !scenarioWeights.isEmpty()) {
+ for (String entry : scenarioWeights.split(",")) {
+ String[] parts = entry.trim().split(":");
+ if (parts.length == 2) {
+ weights.put(parts[0].trim(),
+ Integer.parseInt(parts[1].trim()));
+ }
+ }
+ }
+ return weights;
+ }
+
+ /**
+ * Converts a file name to a valid JavaScript function name. E.g.,
+ * "hello-world.js" -> "helloWorld"
+ */
+ private String fileToScenarioName(Path file) {
+ String name = file.getFileName().toString().replaceAll("\\.js$", "")
+ .replaceAll("-generated$", "");
+
+ // Convert kebab-case to camelCase
+ StringBuilder result = new StringBuilder();
+ boolean capitalizeNext = false;
+ for (char c : name.toCharArray()) {
+ if (c == '-' || c == '_') {
+ capitalizeNext = true;
+ } else if (capitalizeNext) {
+ result.append(Character.toUpperCase(c));
+ capitalizeNext = false;
+ } else {
+ result.append(c);
+ }
+ }
+ return result.toString();
+ }
+
+ /**
+ * Builds the list of test files to run from configuration.
+ */
+ private List getTestFilesToRun() throws MojoExecutionException {
+ List result = new ArrayList<>();
+
+ // Add from testFiles list
+ if (testFiles != null && !testFiles.isEmpty()) {
+ for (File f : testFiles) {
+ Path path = f.toPath().toAbsolutePath();
+ if (!Files.exists(path)) {
+ throw new MojoExecutionException(
+ "Test file not found: " + path);
+ }
+ result.add(path);
+ }
+ }
+
+ // Add single testFile if specified
+ if (testFile != null) {
+ Path path = testFile.toPath().toAbsolutePath();
+ if (!Files.exists(path)) {
+ throw new MojoExecutionException(
+ "Test file not found: " + path);
+ }
+ if (!result.contains(path)) {
+ result.add(path);
+ }
+ }
+
+ // Add all .js files from testDir (excluding helpers, generated, and
+ // combined)
+ if (testDir != null && testDir.exists() && testDir.isDirectory()) {
+ try (Stream files = Files.list(testDir.toPath())) {
+ files.filter(p -> p.toString().endsWith(".js")).filter(
+ p -> !p.getFileName().toString().contains("helper"))
+ .filter(p -> !p.getFileName().toString()
+ .contains("-generated"))
+ .filter(p -> !p.getFileName().toString()
+ .equals("combined-scenarios.js"))
+ .sorted().forEach(p -> {
+ if (!result.contains(p.toAbsolutePath())) {
+ result.add(p.toAbsolutePath());
+ }
+ });
+ } catch (IOException e) {
+ throw new MojoExecutionException(
+ "Failed to list test directory: " + testDir, e);
+ }
+ }
+
+ return result;
+ }
+}
diff --git a/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/K6StartServerMojo.java b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/K6StartServerMojo.java
new file mode 100644
index 000000000..571242272
--- /dev/null
+++ b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/K6StartServerMojo.java
@@ -0,0 +1,140 @@
+/**
+ * Copyright (C) 2000-2026 Vaadin Ltd
+ *
+ * This program is available under Vaadin Commercial License and Service Terms.
+ *
+ * See for the full
+ * license.
+ */
+package com.vaadin.testbench.loadtest;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugins.annotations.LifecyclePhase;
+import org.apache.maven.plugins.annotations.Mojo;
+import org.apache.maven.plugins.annotations.Parameter;
+
+import com.vaadin.testbench.loadtest.util.ServerProcess;
+
+/**
+ * Starts a Spring Boot application server and waits for it to be ready. The
+ * process handle is stored in the Maven project context so that
+ * {@code k6:stop-server} can shut it down later.
+ *
+ * Usage in pom.xml:
+ *
+ *
+ * <execution>
+ * <goals><goal>start-server</goal></goals>
+ * <configuration>
+ * <serverJar>${project.build.directory}/${project.build.finalName}.jar</serverJar>
+ * <serverPort>8081</serverPort>
+ * <managementPort>8082</managementPort>
+ * </configuration>
+ * </execution>
+ *
+ */
+@Mojo(name = "start-server", defaultPhase = LifecyclePhase.PRE_INTEGRATION_TEST)
+public class K6StartServerMojo extends AbstractK6Mojo {
+
+ static final String CONTEXT_KEY = "k6.serverProcess";
+
+ /**
+ * Path to the executable JAR file to start.
+ */
+ @Parameter(property = "k6.serverJar", required = true)
+ private String serverJar;
+
+ /**
+ * Application server port (passed as --server.port).
+ */
+ @Parameter(property = "k6.serverPort", defaultValue = "8080")
+ private int serverPort;
+
+ /**
+ * Spring Boot Actuator management port (passed as
+ * --management.server.port).
+ */
+ @Parameter(property = "k6.managementPort", defaultValue = "8082")
+ private int managementPort;
+
+ /**
+ * Extra JVM arguments (e.g., -Xmx512m).
+ */
+ @Parameter(property = "k6.jvmArgs")
+ private List jvmArgs;
+
+ /**
+ * Extra application arguments (appended after the Spring Boot arguments).
+ */
+ @Parameter(property = "k6.appArgs")
+ private List appArgs;
+
+ /**
+ * Maximum time in seconds to wait for the server to become ready.
+ */
+ @Parameter(property = "k6.startupTimeout", defaultValue = "120")
+ private int startupTimeout;
+
+ /**
+ * Seconds between health check polls.
+ */
+ @Parameter(property = "k6.healthPollInterval", defaultValue = "2")
+ private int healthPollInterval;
+
+ @Override
+ public void execute() throws MojoExecutionException {
+ if (skip) {
+ getLog().info("Skipping start-server");
+ return;
+ }
+
+ // Build command
+ List command = new ArrayList<>();
+ command.add("java");
+ if (jvmArgs != null) {
+ command.addAll(jvmArgs);
+ }
+ command.add("-jar");
+ command.add(serverJar);
+ command.add("--server.port=" + serverPort);
+ command.add("--management.server.port=" + managementPort);
+ if (appArgs != null) {
+ command.addAll(appArgs);
+ }
+
+ // Health URLs to poll
+ List healthUrls = List.of(
+ "http://localhost:" + managementPort + "/actuator/health",
+ "http://localhost:" + serverPort + "/");
+
+ ServerProcess serverProcess = new ServerProcess();
+ try {
+ serverProcess.start(command);
+ serverProcess.waitForReady(healthUrls,
+ Duration.ofSeconds(startupTimeout),
+ Duration.ofSeconds(healthPollInterval));
+ } catch (Exception e) {
+ serverProcess.stop(Duration.ofSeconds(5));
+ throw new MojoExecutionException(
+ "Failed to start server: " + e.getMessage(), e);
+ }
+
+ // Store in project context for stop-server to retrieve
+ project.setContextValue(CONTEXT_KEY, serverProcess);
+
+ // Safety net: kill server if Maven is interrupted before stop-server
+ // runs
+ Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+ if (serverProcess.isAlive()) {
+ serverProcess.stop(Duration.ofSeconds(5));
+ }
+ }, "k6-server-shutdown-hook"));
+
+ getLog().info("Server started on port " + serverPort + " (management: "
+ + managementPort + ")");
+ }
+}
diff --git a/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/K6StopServerMojo.java b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/K6StopServerMojo.java
new file mode 100644
index 000000000..4b7d5c5d1
--- /dev/null
+++ b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/K6StopServerMojo.java
@@ -0,0 +1,64 @@
+/**
+ * Copyright (C) 2000-2026 Vaadin Ltd
+ *
+ * This program is available under Vaadin Commercial License and Service Terms.
+ *
+ * See for the full
+ * license.
+ */
+package com.vaadin.testbench.loadtest;
+
+import java.time.Duration;
+
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugins.annotations.LifecyclePhase;
+import org.apache.maven.plugins.annotations.Mojo;
+import org.apache.maven.plugins.annotations.Parameter;
+
+import com.vaadin.testbench.loadtest.util.ServerProcess;
+
+/**
+ * Stops a server previously started by {@code k6:start-server}. Retrieves the
+ * process handle from the Maven project context.
+ *
+ * Usage in pom.xml:
+ *
+ *
+ * <execution>
+ * <phase>post-integration-test</phase>
+ * <goals><goal>stop-server</goal></goals>
+ * </execution>
+ *
+ */
+@Mojo(name = "stop-server", defaultPhase = LifecyclePhase.POST_INTEGRATION_TEST)
+public class K6StopServerMojo extends AbstractK6Mojo {
+
+ /**
+ * Seconds to wait for graceful shutdown before force-killing the process.
+ */
+ @Parameter(property = "k6.stopGracePeriod", defaultValue = "10")
+ private int gracePeriod;
+
+ @Override
+ public void execute() throws MojoExecutionException {
+ if (skip) {
+ getLog().info("Skipping stop-server");
+ return;
+ }
+
+ Object stored = project.getContextValue(K6StartServerMojo.CONTEXT_KEY);
+ if (!(stored instanceof ServerProcess serverProcess)) {
+ getLog().info(
+ "No server process found (was start-server executed?)");
+ return;
+ }
+
+ if (!serverProcess.isAlive()) {
+ getLog().info("Server process is no longer running");
+ } else {
+ serverProcess.stop(Duration.ofSeconds(gracePeriod));
+ }
+
+ project.setContextValue(K6StartServerMojo.CONTEXT_KEY, null);
+ }
+}
diff --git a/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/ActuatorMetrics.java b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/ActuatorMetrics.java
new file mode 100644
index 000000000..9891fcf71
--- /dev/null
+++ b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/ActuatorMetrics.java
@@ -0,0 +1,375 @@
+/**
+ * Copyright (C) 2000-2026 Vaadin Ltd
+ *
+ * This program is available under Vaadin Commercial License and Service Terms.
+ *
+ * See for the full
+ * license.
+ */
+package com.vaadin.testbench.loadtest.util;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.logging.Logger;
+
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.ObjectMapper;
+
+/**
+ * Fetches metrics from Spring Boot Actuator endpoints.
+ *
+ * Requirements: This utility requires the target application to have
+ * Spring Boot Actuator configured with the metrics endpoint exposed:
+ *
+ *
+ * # In application.properties:
+ * management.server.port=8082
+ * management.endpoints.web.exposure.include=health,metrics
+ *
+ *
+ * Add the actuator dependency to your Spring Boot application:
+ *
+ *
+ * <dependency>
+ * <groupId>org.springframework.boot</groupId>
+ * <artifactId>spring-boot-starter-actuator</artifactId>
+ * </dependency>
+ *
+ *
+ * Vaadin Metrics: For Vaadin-specific metrics collection, the target
+ * application must implement custom VaadinActuator or equivalent that exposes
+ * Vaadin view metrics. This is not part of standard Spring Boot Actuator and
+ * must be implemented by the user.
+ *
+ * @see Spring
+ * Boot Actuator Endpoints
+ */
+public class ActuatorMetrics {
+
+ private static final Logger log = Logger
+ .getLogger(ActuatorMetrics.class.getName());
+ private final HttpClient httpClient;
+ private final ObjectMapper objectMapper;
+ private final String baseUrl;
+ private final boolean collectVaadinMetrics;
+
+ /**
+ * Creates an ActuatorMetrics instance.
+ *
+ * @param host
+ * actuator host (e.g., "localhost")
+ * @param managementPort
+ * actuator management port (e.g., 8082)
+ * @param collectVaadinMetrics
+ * whether to collect Vaadin-specific metrics (requires custom
+ * VaadinActuator implementation)
+ */
+ public ActuatorMetrics(String host, int managementPort,
+ boolean collectVaadinMetrics) {
+ this.baseUrl = "http://" + host + ":" + managementPort + "/actuator";
+ this.httpClient = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(5)).build();
+ this.objectMapper = new ObjectMapper();
+ this.collectVaadinMetrics = collectVaadinMetrics;
+ }
+
+ /**
+ * Creates an ActuatorMetrics instance (backward compatibility constructor).
+ * Vaadin metrics collection is disabled by default.
+ *
+ * @param host
+ * actuator host (e.g., "localhost")
+ * @param managementPort
+ * actuator management port (e.g., 8082)
+ */
+ public ActuatorMetrics(String host, int managementPort) {
+ this(host, managementPort, false);
+ }
+
+ /**
+ * Fetches and returns a summary of server metrics after a load test.
+ *
+ * @return metrics summary, or empty if actuator is not available
+ */
+ public Optional fetchMetrics() {
+ try {
+ // Check if actuator is available
+ if (!isActuatorAvailable()) {
+ log.fine("Actuator endpoint not available at " + baseUrl);
+ return Optional.empty();
+ }
+
+ Double cpuUsage = fetchMetricValue("process.cpu.usage");
+ Double systemCpuUsage = fetchMetricValue("system.cpu.usage");
+ Long heapUsed = fetchMetricValueAsLong("jvm.memory.used", "area",
+ "heap");
+ Long heapMax = fetchMetricValueAsLong("jvm.memory.max", "area",
+ "heap");
+ Long nonHeapUsed = fetchMetricValueAsLong("jvm.memory.used", "area",
+ "nonheap");
+ Long activeSessions = fetchMetricValueAsLong(
+ "tomcat.sessions.active.current");
+
+ // Fetch Vaadin-specific metrics if enabled
+ Long vaadinActiveUis = null;
+ Map viewCounts = new LinkedHashMap<>();
+ if (collectVaadinMetrics) {
+ vaadinActiveUis = fetchMetricValueAsLong("vaadin.view.count");
+ viewCounts = fetchViewCounts();
+ }
+
+ return Optional.of(
+ new MetricsSummary(cpuUsage != null ? cpuUsage * 100 : null,
+ systemCpuUsage != null ? systemCpuUsage * 100
+ : null,
+ heapUsed, heapMax, nonHeapUsed, activeSessions,
+ vaadinActiveUis, viewCounts));
+
+ } catch (Exception e) {
+ log.fine("Failed to fetch actuator metrics: " + e.getMessage());
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * Checks if the actuator health endpoint is available.
+ */
+ private boolean isActuatorAvailable() {
+ try {
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(baseUrl + "/health"))
+ .timeout(Duration.ofSeconds(3)).GET().build();
+
+ HttpResponse response = httpClient.send(request,
+ HttpResponse.BodyHandlers.ofString());
+ return response.statusCode() == 200;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ /**
+ * Fetches a single metric value.
+ *
+ * @param metricName
+ * the actuator metric name
+ * @return the metric value, or {@code null} if unavailable
+ */
+ private Double fetchMetricValue(String metricName) {
+ try {
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(baseUrl + "/metrics/" + metricName))
+ .timeout(Duration.ofSeconds(5)).GET().build();
+
+ HttpResponse response = httpClient.send(request,
+ HttpResponse.BodyHandlers.ofString());
+ if (response.statusCode() == 200) {
+ JsonNode root = objectMapper.readTree(response.body());
+ JsonNode measurements = root.get("measurements");
+ if (measurements != null && measurements.isArray()
+ && !measurements.isEmpty()) {
+ return measurements.get(0).get("value").asDouble();
+ }
+ }
+ } catch (IOException | InterruptedException e) {
+ log.fine("Failed to fetch metric " + metricName + ": "
+ + e.getMessage());
+ }
+ return null;
+ }
+
+ /**
+ * Fetches a metric value as Long without tag filter.
+ *
+ * @param metricName
+ * the actuator metric name
+ * @return the metric value as a Long, or {@code null} if unavailable
+ */
+ private Long fetchMetricValueAsLong(String metricName) {
+ Double value = fetchMetricValue(metricName);
+ return value != null ? value.longValue() : null;
+ }
+
+ /**
+ * Fetches a metric value with a specific tag filter.
+ *
+ * @param metricName
+ * the actuator metric name
+ * @param tagName
+ * the tag name to filter by
+ * @param tagValue
+ * the tag value to filter by
+ * @return the metric value as a Long, or {@code null} if unavailable
+ */
+ private Long fetchMetricValueAsLong(String metricName, String tagName,
+ String tagValue) {
+ try {
+ String url = baseUrl + "/metrics/" + metricName + "?tag=" + tagName
+ + ":" + tagValue;
+ HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url))
+ .timeout(Duration.ofSeconds(5)).GET().build();
+
+ HttpResponse response = httpClient.send(request,
+ HttpResponse.BodyHandlers.ofString());
+ if (response.statusCode() == 200) {
+ JsonNode root = objectMapper.readTree(response.body());
+ JsonNode measurements = root.get("measurements");
+ if (measurements != null && measurements.isArray()
+ && !measurements.isEmpty()) {
+ return measurements.get(0).get("value").asLong();
+ }
+ }
+ } catch (IOException | InterruptedException e) {
+ log.fine("Failed to fetch metric " + metricName + ": "
+ + e.getMessage());
+ }
+ return null;
+ }
+
+ /**
+ * Fetches view counts for all available views. Returns a map of view name
+ * (simple class name) to count.
+ *
+ * @return map of view name to count
+ */
+ private Map fetchViewCounts() {
+ Map viewCounts = new LinkedHashMap<>();
+ try {
+ // First, get available tags for vaadin.view.count
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(baseUrl + "/metrics/vaadin.view.count"))
+ .timeout(Duration.ofSeconds(5)).GET().build();
+
+ HttpResponse response = httpClient.send(request,
+ HttpResponse.BodyHandlers.ofString());
+ if (response.statusCode() == 200) {
+ JsonNode root = objectMapper.readTree(response.body());
+ JsonNode availableTags = root.get("availableTags");
+ if (availableTags != null && availableTags.isArray()) {
+ for (JsonNode tagNode : availableTags) {
+ if ("view".equals(tagNode.get("tag").asText())) {
+ JsonNode values = tagNode.get("values");
+ if (values != null && values.isArray()) {
+ for (JsonNode viewName : values) {
+ String name = viewName.asText();
+ Long count = fetchMetricValueAsLong(
+ "vaadin.view.count", "view", name);
+ if (count != null) {
+ viewCounts.put(name, count);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ } catch (IOException | InterruptedException e) {
+ log.fine("Failed to fetch view counts: " + e.getMessage());
+ }
+ return viewCounts;
+ }
+
+ /**
+ * Summary of server metrics.
+ *
+ * @param processCpuPercent
+ * process CPU usage percentage (0-100)
+ * @param systemCpuPercent
+ * system CPU usage percentage (0-100)
+ * @param heapUsedBytes
+ * heap memory used in bytes
+ * @param heapMaxBytes
+ * heap memory max in bytes
+ * @param nonHeapUsedBytes
+ * non-heap memory used in bytes
+ * @param activeSessions
+ * number of active HTTP sessions
+ * @param vaadinActiveUis
+ * number of active Vaadin UI instances (requires custom
+ * VaadinActuator implementation)
+ * @param viewCounts
+ * map of view name to active count (requires custom
+ * VaadinActuator implementation)
+ */
+ public record MetricsSummary(Double processCpuPercent,
+ Double systemCpuPercent, Long heapUsedBytes, Long heapMaxBytes,
+ Long nonHeapUsedBytes, Long activeSessions, Long vaadinActiveUis,
+ Map viewCounts) {
+ /**
+ * Formats bytes to human-readable format.
+ */
+ public String formatBytes(Long bytes) {
+ if (bytes == null)
+ return "N/A";
+ String sign = bytes < 0 ? "-" : "";
+ long abs = Math.abs(bytes);
+ if (abs < 1024)
+ return sign + abs + " B";
+ if (abs < 1024 * 1024)
+ return String.format("%s%.1f KB", sign, abs / 1024.0);
+ if (abs < 1024 * 1024 * 1024)
+ return String.format("%s%.1f MB", sign, abs / (1024.0 * 1024));
+ return String.format("%s%.2f GB", sign,
+ abs / (1024.0 * 1024 * 1024));
+ }
+
+ /**
+ * Returns heap usage as a percentage.
+ */
+ public String heapUsagePercent() {
+ if (heapUsedBytes == null || heapMaxBytes == null
+ || heapMaxBytes == 0) {
+ return "N/A";
+ }
+ return String.format("%.1f%%",
+ (heapUsedBytes * 100.0) / heapMaxBytes);
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder();
+ sb.append("Server Metrics (via Spring Boot Actuator):\n");
+ if (processCpuPercent != null) {
+ sb.append(String.format(" Process CPU: %.1f%%\n",
+ processCpuPercent));
+ }
+ if (systemCpuPercent != null) {
+ sb.append(String.format(" System CPU: %.1f%%\n",
+ systemCpuPercent));
+ }
+ if (heapUsedBytes != null) {
+ sb.append(String.format(" Heap Used: %s / %s (%s)\n",
+ formatBytes(heapUsedBytes), formatBytes(heapMaxBytes),
+ heapUsagePercent()));
+ }
+ if (nonHeapUsedBytes != null) {
+ sb.append(String.format(" Non-Heap: %s\n",
+ formatBytes(nonHeapUsedBytes)));
+ }
+ if (activeSessions != null) {
+ sb.append(
+ String.format(" HTTP Sessions: %d\n", activeSessions));
+ }
+ if (vaadinActiveUis != null) {
+ sb.append(
+ String.format(" Vaadin UIs: %d\n", vaadinActiveUis));
+ }
+ if (viewCounts != null && !viewCounts.isEmpty()) {
+ sb.append(" Views:\n");
+ for (Map.Entry entry : viewCounts.entrySet()) {
+ sb.append(String.format(" %s: %d\n", entry.getKey(),
+ entry.getValue()));
+ }
+ }
+ return sb.toString();
+ }
+ }
+}
diff --git a/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/HarFilter.java b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/HarFilter.java
new file mode 100644
index 000000000..74f4f36d9
--- /dev/null
+++ b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/HarFilter.java
@@ -0,0 +1,273 @@
+/**
+ * Copyright (C) 2000-2026 Vaadin Ltd
+ *
+ * This program is available under Vaadin Commercial License and Service Terms.
+ *
+ * See for the full
+ * license.
+ */
+package com.vaadin.testbench.loadtest.util;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.logging.Logger;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import tools.jackson.core.StreamReadConstraints;
+import tools.jackson.core.json.JsonFactory;
+import tools.jackson.databind.ObjectMapper;
+import tools.jackson.databind.SerializationFeature;
+import tools.jackson.databind.json.JsonMapper;
+
+/**
+ * Filters out requests to external domains from a HAR file. This removes
+ * browser background traffic (Google services, telemetry, etc.) that isn't part
+ * of the application under test.
+ */
+public class HarFilter {
+
+ private static final List EXTERNAL_DOMAINS = List.of(
+ // Google services (browser background requests)
+ "google.com", "googleapis.com", "gstatic.com",
+ "googleusercontent.com", "google-analytics.com",
+ // Mozilla services
+ "mozilla.com", "mozilla.org", "firefox.com",
+ // Microsoft services
+ "microsoft.com", "msn.com", "live.com",
+ // Apple services
+ "apple.com", "icloud.com",
+ // Common CDNs and analytics (usually not part of app testing)
+ "cloudflare.com", "akamai.net", "fastly.net");
+
+ private static final Logger log = Logger
+ .getLogger(HarFilter.class.getName());
+ private final ObjectMapper objectMapper;
+
+ /**
+ * Creates a new HAR filter with a configured Jackson ObjectMapper.
+ */
+ public HarFilter() {
+ JsonFactory jsonFactory = JsonFactory.builder()
+ .streamReadConstraints(StreamReadConstraints.builder()
+ .maxStringLength(Integer.MAX_VALUE).build())
+ .build();
+ this.objectMapper = JsonMapper.builder(jsonFactory)
+ .enable(SerializationFeature.INDENT_OUTPUT).build();
+ }
+
+ /**
+ * Filters a HAR file in place, removing requests to external domains.
+ *
+ * @param harFile
+ * the path to the HAR file
+ * @return the filter result with statistics
+ * @throws IOException
+ * if reading or writing fails
+ */
+ public FilterResult filter(Path harFile) throws IOException {
+ return filter(harFile, harFile);
+ }
+
+ /**
+ * Filters a HAR file, removing requests to external domains.
+ *
+ * @param inputFile
+ * the input HAR file
+ * @param outputFile
+ * the output HAR file (can be same as input)
+ * @return the filter result with statistics
+ * @throws IOException
+ * if reading or writing fails
+ */
+ public FilterResult filter(Path inputFile, Path outputFile)
+ throws IOException {
+ log.info("Filtering external domains from HAR file...");
+
+ HarFile har = objectMapper.readValue(inputFile.toFile(), HarFile.class);
+
+ int originalCount = har.log().entries().size();
+ List filteredEntries = new ArrayList<>();
+
+ for (HarEntry entry : har.log().entries()) {
+ if (entry.request() != null && entry.request().url() != null) {
+ String url = entry.request().url();
+ if (isExternalDomain(url)) {
+ String truncatedUrl = url.length() > 80
+ ? url.substring(0, 80) + "..."
+ : url;
+ log.fine(" Filtered: " + truncatedUrl);
+ continue;
+ }
+ // Filter Vaadin session unload requests (sent when browser tab
+ // closes)
+ if (isUnloadRequest(entry)) {
+ log.fine(" Filtered UNLOAD request: " + url);
+ continue;
+ }
+ }
+ filteredEntries.add(entry);
+ }
+
+ // Create new HAR with filtered entries
+ HarFile filteredHar = new HarFile(new HarLog(har.log().version(),
+ har.log().creator(), filteredEntries, har.log().pages()));
+
+ objectMapper.writeValue(outputFile.toFile(), filteredHar);
+
+ int filteredCount = originalCount - filteredEntries.size();
+ int remainingCount = filteredEntries.size();
+
+ log.info("Done! " + filteredCount + " of " + originalCount
+ + " requests filtered.");
+ log.info("Remaining: " + remainingCount + " requests");
+
+ return new FilterResult(originalCount, filteredCount, remainingCount);
+ }
+
+ /**
+ * Check if a request is a Vaadin session unload (sent when the browser tab
+ * closes). These contain {@code "UNLOAD":true} in the POST body and should
+ * not be replayed.
+ *
+ * @param entry
+ * the HAR entry to check
+ * @return {@code true} if the entry is a session unload request
+ */
+ private boolean isUnloadRequest(HarEntry entry) {
+ if (entry.request().postData() != null
+ && entry.request().postData().text() != null) {
+ return entry.request().postData().text()
+ .contains("\"UNLOAD\":true");
+ }
+ return false;
+ }
+
+ /**
+ * Check if a URL belongs to an external domain that should be filtered.
+ *
+ * @param url
+ * the URL to check
+ * @return {@code true} if the URL belongs to a known external domain
+ */
+ private boolean isExternalDomain(String url) {
+ try {
+ URI uri = new URI(url);
+ String hostname = uri.getHost();
+ if (hostname == null) {
+ return false;
+ }
+ hostname = hostname.toLowerCase();
+
+ for (String domain : EXTERNAL_DOMAINS) {
+ if (hostname.equals(domain)
+ || hostname.endsWith("." + domain)) {
+ return true;
+ }
+ }
+ return false;
+ } catch (URISyntaxException e) {
+ // If URL parsing fails, keep the entry
+ return false;
+ }
+ }
+
+ /**
+ * Result of filtering operation.
+ */
+ public record FilterResult(int originalCount, int filteredCount,
+ int remainingCount) {
+ }
+
+ // HAR format DTOs using Java records
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record HarFile(HarLog log) {
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record HarLog(String version, HarCreator creator,
+ List entries, List pages) {
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record HarCreator(String name, String version) {
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record HarPage(String startedDateTime, String id, String title,
+ HarPageTimings pageTimings) {
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record HarPageTimings(Double onContentLoad, Double onLoad) {
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record HarEntry(String startedDateTime, Double time,
+ HarRequest request, HarResponse response, HarCache cache,
+ HarTimings timings, String serverIPAddress, String connection,
+ String pageref) {
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record HarRequest(String method, String url, String httpVersion,
+ List headers, List queryString,
+ List cookies, Integer headersSize, Integer bodySize,
+ HarPostData postData) {
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record HarResponse(Integer status, String statusText,
+ String httpVersion, List headers,
+ List cookies, HarContent content, String redirectURL,
+ Integer headersSize, Integer bodySize) {
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record HarHeader(String name, String value) {
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record HarQueryString(String name, String value) {
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record HarCookie(String name, String value, String path,
+ String domain, String expires, Boolean httpOnly, Boolean secure) {
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record HarPostData(String mimeType, String text,
+ List params) {
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record HarParam(String name, String value, String fileName,
+ String contentType) {
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record HarContent(Long size, Long compression, String mimeType,
+ String text, String encoding) {
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record HarCache(HarCacheEntry beforeRequest,
+ HarCacheEntry afterRequest) {
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record HarCacheEntry(String expires, String lastAccess, String eTag,
+ Integer hitCount) {
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record HarTimings(Double blocked, Double dns, Double connect,
+ Double send, @JsonProperty("wait") Double waitTime, Double receive,
+ Double ssl) {
+ }
+}
diff --git a/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/HarToK6Converter.java b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/HarToK6Converter.java
new file mode 100644
index 000000000..b9f279297
--- /dev/null
+++ b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/HarToK6Converter.java
@@ -0,0 +1,818 @@
+/**
+ * Copyright (C) 2000-2026 Vaadin Ltd
+ *
+ * This program is available under Vaadin Commercial License and Service Terms.
+ *
+ * See for the full
+ * license.
+ */
+package com.vaadin.testbench.loadtest.util;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.logging.Logger;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import tools.jackson.core.StreamReadConstraints;
+import tools.jackson.core.json.JsonFactory;
+import tools.jackson.databind.ObjectMapper;
+import tools.jackson.databind.json.JsonMapper;
+
+/**
+ * Converts HAR files to k6 test scripts. This replaces the npm har-to-k6
+ * package with a pure Java implementation.
+ */
+public class HarToK6Converter {
+
+ private static final Logger log = Logger
+ .getLogger(HarToK6Converter.class.getName());
+ private static final Pattern SYNC_CLIENT_ID_PATTERN = Pattern
+ .compile("\"syncId\":-?\\d+,\"clientId\":-?\\d+");
+
+ /** Matches v-uiId=N in URLs (any numeric value). */
+ private static final Pattern UI_ID_URL_PATTERN = Pattern
+ .compile("v-uiId=\\d+");
+
+ /** Matches csrfToken UUID in UIDL POST bodies. */
+ private static final Pattern CSRF_TOKEN_BODY_PATTERN = Pattern.compile(
+ "\"csrfToken\":\"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\"");
+
+ /**
+ * Matches select/setDetailsVisible calls with entity key args in UIDL
+ * bodies.
+ */
+ private static final Pattern ENTITY_KEY_ARG_PATTERN = Pattern.compile(
+ "\"templateEventMethodName\":\"(select|setDetailsVisible)\",\"templateEventMethodArgs\":\\[\"(\\d+)\"\\]");
+
+ /** Matches mSync input values (user-entered form data) in UIDL bodies. */
+ private static final Pattern MSYNC_INPUT_VALUE_PATTERN = Pattern.compile(
+ "\"type\":\"mSync\",\"node\":\\d+,\"feature\":\\d+,\"property\":\"value\",\"value\":\"([^\"]+)\"");
+
+ private static final String K6_SCRIPT_IMPORTS = """
+ // Auto-generated by testbench-converter-plugin
+
+ import http from 'k6/http'
+ import { check, fail } from 'k6'
+ import { sleep } from 'k6'
+
+ """;
+
+ private static final String K6_SCRIPT_FUNCTION_START = """
+ export default function () {
+ """;
+
+ private static final String K6_SCRIPT_FOOTER = """
+ }
+ """;
+
+ /**
+ * Collected mSync input values across all requests during a single
+ * conversion.
+ */
+ private final List collectedInputValues = new ArrayList<>();
+
+ private final ObjectMapper objectMapper;
+
+ /**
+ * Creates a new HAR to k6 converter with a configured Jackson ObjectMapper.
+ */
+ public HarToK6Converter() {
+ JsonFactory jsonFactory = JsonFactory.builder()
+ .streamReadConstraints(StreamReadConstraints.builder()
+ .maxStringLength(Integer.MAX_VALUE).build())
+ .build();
+ this.objectMapper = JsonMapper.builder(jsonFactory).build();
+ }
+
+ /**
+ * Converts a HAR file to a k6 test script with default thresholds.
+ *
+ * @param harFile
+ * the input HAR file
+ * @param outputFile
+ * the output k6 test script
+ * @throws IOException
+ * if reading or writing fails
+ */
+ public void convert(Path harFile, Path outputFile) throws IOException {
+ convert(harFile, outputFile, ThresholdConfig.DEFAULT);
+ }
+
+ /**
+ * Converts a HAR file to a k6 test script with configurable thresholds.
+ *
+ * @param harFile
+ * the input HAR file
+ * @param outputFile
+ * the output k6 test script
+ * @param thresholdConfig
+ * threshold configuration for the generated script
+ * @throws IOException
+ * if reading or writing fails
+ */
+ public void convert(Path harFile, Path outputFile,
+ ThresholdConfig thresholdConfig) throws IOException {
+ log.info("Converting HAR to k6 test...");
+ collectedInputValues.clear();
+
+ HarFile har = objectMapper.readValue(harFile.toFile(), HarFile.class);
+
+ List entries = har.log().entries();
+ log.info(" Processing " + entries.size() + " requests...");
+
+ // Detect Vaadin session values to make them dynamic per VU
+ VaadinSessionInfo session = detectVaadinSession(entries);
+
+ StringBuilder script = new StringBuilder();
+ script.append(K6_SCRIPT_IMPORTS);
+ script.append("export const options = {\n");
+ script.append(thresholdConfig.toK6ThresholdsBlock());
+ script.append("}\n\n");
+ script.append(K6_SCRIPT_FUNCTION_START);
+
+ for (int i = 0; i < entries.size(); i++) {
+ HarEntry entry = entries.get(i);
+ // Calculate time delta from previous request
+ long deltaMs = -1;
+ if (i > 0) {
+ deltaMs = calculateDeltaMs(entries.get(i - 1), entry);
+ }
+ String requestCode = generateRequestCode(entry, i, deltaMs,
+ session);
+ script.append(requestCode);
+
+ // Request label for error messages (e.g., "Request 6 (GET
+ // ...v-r=init...)")
+ String requestLabel = "Request " + (i + 1) + " ("
+ + entry.request().method() + " "
+ + truncateUrl(entry.request().url()) + ")";
+
+ // After first init request, inject code to declare and extract
+ // Vaadin session values
+ if (session != null && i == session.initRequestIndex()) {
+ script.append(generateSessionExtractionCode(requestLabel));
+ }
+
+ // After subsequent init requests (e.g., post-login), re-extract
+ // session values
+ if (session != null && session.isSubsequentInitRequest(i)) {
+ script.append(generateSessionReExtractionCode());
+ }
+
+ // After each UIDL POST, extract syncId from response and increment
+ // clientId
+ if (session != null && isUidlRequest(entry)) {
+ script.append(generateUidlResponseExtractionCode(requestLabel));
+ }
+ }
+
+ script.append(K6_SCRIPT_FOOTER);
+
+ String scriptContent = script.toString();
+
+ // If mSync input values were detected, add CSV data loading to the
+ // script
+ if (!collectedInputValues.isEmpty()) {
+ scriptContent = addCsvDataLoading(scriptContent, outputFile);
+ Path csvFile = deriveCsvPath(outputFile);
+ writeCsvFile(csvFile);
+ log.info(" Input data CSV: " + csvFile + " ("
+ + collectedInputValues.size() + " fields)");
+ }
+
+ Files.writeString(outputFile, scriptContent);
+ log.info("HAR converted to k6 test: " + outputFile);
+ }
+
+ /**
+ * Generates k6 request code for a HAR entry.
+ *
+ * @param entry
+ * the HAR entry
+ * @param index
+ * the request index
+ * @param deltaMs
+ * time delta from previous request in milliseconds, or -1 for
+ * first request
+ * @param session
+ * detected Vaadin session info, or null if not a Vaadin app
+ * @return the generated k6 request code
+ */
+ private String generateRequestCode(HarEntry entry, int index, long deltaMs,
+ VaadinSessionInfo session) {
+ HarRequest request = entry.request();
+ String method = request.method().toUpperCase();
+ String url = request.url();
+
+ // Determine if this request needs dynamic Vaadin session values
+ boolean pastInit = session != null
+ && index > session.initRequestIndex();
+ boolean dynamicUrl = pastInit && UI_ID_URL_PATTERN.matcher(url).find();
+
+ StringBuilder code = new StringBuilder();
+ // Include HAR timing delta as a comment for the refactorer to use
+ if (deltaMs >= 0) {
+ code.append(" // HAR_DELTA_MS: ").append(deltaMs).append("\n");
+ }
+ code.append(" // Request ").append(index + 1).append(": ")
+ .append(method).append(" ").append(truncateUrl(url))
+ .append("\n");
+
+ // Generate headers object, skipping Cookie headers (k6 cookie jar
+ // handles these)
+ StringBuilder headers = new StringBuilder();
+ headers.append("{\n");
+ if (request.headers() != null && !request.headers().isEmpty()) {
+ List headerLines = new ArrayList<>();
+ for (HarHeader header : request.headers()) {
+ String name = header.name().toLowerCase();
+ // Skip pseudo-headers, auto-generated headers, cookies, and
+ // encoding
+ // (Accept-Encoding is removed so server returns uncompressed
+ // responses
+ // that can be parsed for Vaadin session values)
+ if (name.startsWith(":") || name.equals("content-length")
+ || name.equals("host") || name.equals("connection")
+ || name.equals("cookie")
+ || name.equals("accept-encoding")) {
+ continue;
+ }
+ headerLines.add(" '" + escapeJs(header.name()) + "': '"
+ + escapeJs(header.value()) + "'");
+ }
+ headers.append(String.join(",\n", headerLines));
+ if (!headerLines.isEmpty()) {
+ headers.append("\n");
+ }
+ }
+ headers.append(" }");
+
+ // Note: cookies are NOT added as headers — k6 cookie jar manages
+ // JSESSIONID automatically
+
+ // Generate the request (use 'let' only for first request)
+ String responseDecl = index == 0 ? "let response" : "response";
+
+ if ("GET".equals(method) || "HEAD".equals(method)
+ || "OPTIONS".equals(method)) {
+ code.append(" ").append(responseDecl).append(" = http.")
+ .append(method.toLowerCase()).append("(\n");
+ if (dynamicUrl) {
+ String escapedUrl = UI_ID_URL_PATTERN
+ .matcher(escapeJsTemplate(url))
+ .replaceAll(Matcher.quoteReplacement("v-uiId=${uiId}"));
+ code.append(" `").append(escapedUrl).append("`,\n");
+ } else {
+ code.append(" '").append(escapeJs(url)).append("',\n");
+ }
+ code.append(" {\n");
+ code.append(" headers: ").append(headers).append("\n");
+ code.append(" }\n");
+ code.append(" )\n\n");
+ } else {
+ // POST, PUT, PATCH, DELETE with body
+ String body = "";
+ if (request.postData() != null
+ && request.postData().text() != null) {
+ body = request.postData().text();
+ }
+ boolean hasCsrfToken = pastInit
+ && CSRF_TOKEN_BODY_PATTERN.matcher(body).find();
+ boolean hasSyncCounters = pastInit
+ && SYNC_CLIENT_ID_PATTERN.matcher(body).find();
+ // Detect mSync input values (user-entered form data)
+ List msyncValues = new ArrayList<>();
+ boolean hasMsyncInputs = false;
+ if (pastInit) {
+ Matcher msyncMatcher = MSYNC_INPUT_VALUE_PATTERN.matcher(body);
+ while (msyncMatcher.find()) {
+ msyncValues.add(msyncMatcher.group(1));
+ hasMsyncInputs = true;
+ }
+ }
+ // Body needs template literals if it contains any dynamic values
+ boolean dynamicBody = hasCsrfToken || hasSyncCounters
+ || hasMsyncInputs;
+
+ // Detect grid entity key args in select/setDetailsVisible calls
+ String entityKey = null;
+ if (dynamicBody) {
+ Matcher entityKeyMatcher = ENTITY_KEY_ARG_PATTERN.matcher(body);
+ if (entityKeyMatcher.find()) {
+ entityKey = entityKeyMatcher.group(2);
+ }
+ }
+
+ // Pick a random grid key before requests that select/edit entities
+ if (entityKey != null) {
+ code.append(
+ " selectedKey = gridKeys.length > 0 ? gridKeys[Math.floor(Math.random() * gridKeys.length)] : '0'\n");
+ }
+
+ code.append(" ").append(responseDecl).append(" = http.")
+ .append(method.toLowerCase()).append("(\n");
+
+ if (dynamicUrl) {
+ String escapedUrl = UI_ID_URL_PATTERN
+ .matcher(escapeJsTemplate(url))
+ .replaceAll(Matcher.quoteReplacement("v-uiId=${uiId}"));
+ code.append(" `").append(escapedUrl).append("`,\n");
+ } else {
+ code.append(" '").append(escapeJs(url)).append("',\n");
+ }
+
+ if (dynamicBody) {
+ String escapedBody = escapeJsTemplate(body);
+ // Replace hardcoded csrfToken UUID with dynamic variable
+ if (hasCsrfToken) {
+ escapedBody = CSRF_TOKEN_BODY_PATTERN.matcher(escapedBody)
+ .replaceAll(Matcher.quoteReplacement(
+ "\"csrfToken\":\"${csrfToken}\""));
+ }
+ // Replace hardcoded syncId/clientId with dynamic variables
+ if (hasSyncCounters) {
+ escapedBody = SYNC_CLIENT_ID_PATTERN.matcher(escapedBody)
+ .replaceFirst(Matcher.quoteReplacement(
+ "\"syncId\":${syncId},\"clientId\":${clientId}"));
+ }
+ // Replace hardcoded entity keys with dynamic grid key
+ if (entityKey != null) {
+ escapedBody = escapedBody.replace(
+ "templateEventMethodArgs\":[\"" + entityKey + "\"]",
+ "templateEventMethodArgs\":[\"${selectedKey}\"]");
+ }
+ // Replace mSync input values with CSV data references
+ for (String msyncValue : msyncValues) {
+ int inputIndex = collectedInputValues.size() + 1; // 1-based
+ collectedInputValues.add(msyncValue);
+ String escapedValue = escapeJsTemplate(msyncValue);
+ escapedBody = escapedBody.replaceFirst(
+ Pattern.quote("\"property\":\"value\",\"value\":\""
+ + escapedValue + "\""),
+ Matcher.quoteReplacement(
+ "\"property\":\"value\",\"value\":\"${inputRow.input_"
+ + inputIndex + "}\""));
+ }
+ code.append(" `").append(escapedBody).append("`,\n");
+ } else {
+ code.append(" '").append(escapeJs(body)).append("',\n");
+ }
+
+ code.append(" {\n");
+ code.append(" headers: ").append(headers).append("\n");
+ code.append(" }\n");
+ code.append(" )\n\n");
+ }
+
+ return code.toString();
+ }
+
+ /**
+ * Detects Vaadin session values from HAR entries. Finds all init requests,
+ * recorded JSESSIONID, csrfToken, and uiId. Supports login flows where a
+ * second init request occurs after authentication.
+ *
+ * @param entries
+ * the HAR entries to scan
+ * @return session info if a Vaadin init request is found, null otherwise
+ */
+ private VaadinSessionInfo detectVaadinSession(List entries) {
+ int firstInitRequestIndex = -1;
+ List allInitRequestIndices = new ArrayList<>();
+ String jsessionId = null;
+ String csrfToken = null;
+ String uiId = null;
+
+ Pattern csrfPattern = Pattern
+ .compile("\"csrfToken\"\\s*:\\s*\"([^\"]+)\"");
+ Pattern uiIdPattern = Pattern.compile("v-uiId=(\\d+)");
+
+ for (int i = 0; i < entries.size(); i++) {
+ HarEntry entry = entries.get(i);
+ String url = entry.request().url();
+
+ // Find all init requests (login flows have multiple)
+ if (url.contains("v-r=init")) {
+ allInitRequestIndices.add(i);
+ if (firstInitRequestIndex == -1) {
+ firstInitRequestIndex = i;
+ }
+ }
+
+ // Find JSESSIONID from cookies
+ if (jsessionId == null && entry.request().cookies() != null) {
+ for (HarCookie cookie : entry.request().cookies()) {
+ if ("JSESSIONID".equalsIgnoreCase(cookie.name())) {
+ jsessionId = cookie.value();
+ break;
+ }
+ }
+ }
+
+ // Find csrfToken from POST body
+ if (csrfToken == null && entry.request().postData() != null
+ && entry.request().postData().text() != null) {
+ Matcher m = csrfPattern
+ .matcher(entry.request().postData().text());
+ if (m.find()) {
+ csrfToken = m.group(1);
+ }
+ }
+
+ // Find uiId from URL
+ if (uiId == null) {
+ Matcher m = uiIdPattern.matcher(url);
+ if (m.find()) {
+ uiId = m.group(1);
+ }
+ }
+ }
+
+ if (firstInitRequestIndex >= 0) {
+ log.info(
+ " Detected Vaadin session: initRequests="
+ + allInitRequestIndices.size() + " (first=#"
+ + (firstInitRequestIndex + 1) + ")" + ", uiId="
+ + uiId + ", csrfToken="
+ + (csrfToken != null ? csrfToken.substring(0,
+ Math.min(8, csrfToken.length())) + "..."
+ : "null"));
+ return new VaadinSessionInfo(firstInitRequestIndex,
+ allInitRequestIndices, jsessionId, csrfToken, uiId);
+ }
+ return null;
+ }
+
+ /**
+ * Generates k6 code to extract Vaadin session values from the init
+ * response. This code is injected after the init request in the generated
+ * script.
+ *
+ * @param requestLabel
+ * human-readable label for error messages (e.g., "Request 6 (GET
+ * ...)")
+ * @return the generated extraction code
+ */
+ private String generateSessionExtractionCode(String requestLabel) {
+ StringBuilder code = new StringBuilder();
+ code.append(
+ " // Abort iteration and trigger test abort if init response is invalid\n");
+ code.append(" if (!check(response, {\n");
+ code.append(" 'init request succeeded': (r) => r.status === 200,\n");
+ code.append(
+ " 'session is valid': (r) => !r.body.includes('Your session needs to be refreshed'),\n");
+ code.append(
+ " 'valid init response': (r) => r.body.includes('\"v-uiId\"') && r.body.includes('\"Vaadin-Security-Key\"'),\n");
+ code.append(" })) {\n");
+ code.append(" fail(`").append(escapeJsTemplate(requestLabel)).append(
+ ": init failed (status ${response.status}): ${response.body.substring(0, 200)}`)\n");
+ code.append(" }\n\n");
+ code.append(" // Extract Vaadin session values from init response\n");
+ code.append(
+ " let uiId = response.body.match(/\"v-uiId\"\\s*:\\s*(\\d+)/)[1]\n");
+ code.append(
+ " let csrfToken = response.body.match(/\"Vaadin-Security-Key\"\\s*:\\s*\"([^\"]+)\"/)[1]\n");
+ code.append(" let syncId = 0\n");
+ code.append(" let clientId = 0\n");
+ code.append(" let gridKeys = []\n");
+ code.append(" let selectedKey = '0'\n");
+ code.append("\n");
+ return code.toString();
+ }
+
+ /**
+ * Generates k6 code to re-extract Vaadin session values after a subsequent
+ * init request (e.g., after login when the security key changes). Uses
+ * reassignment instead of let declarations since variables already exist.
+ *
+ * @return the generated re-extraction code
+ */
+ private String generateSessionReExtractionCode() {
+ StringBuilder code = new StringBuilder();
+ code.append(
+ " // Re-extract Vaadin session values after login/navigation\n");
+ code.append(
+ " if (response.status === 200 && response.body.includes('\"Vaadin-Security-Key\"')) {\n");
+ code.append(
+ " var newUiId = response.body.match(/\"v-uiId\"\\s*:\\s*(\\d+)/)\n");
+ code.append(
+ " var newCsrf = response.body.match(/\"Vaadin-Security-Key\"\\s*:\\s*\"([^\"]+)\"/)\n");
+ code.append(" if (newUiId) uiId = newUiId[1]\n");
+ code.append(" if (newCsrf) csrfToken = newCsrf[1]\n");
+ code.append(" syncId = 0\n");
+ code.append(" clientId = 0\n");
+ code.append(" }\n");
+ code.append("\n");
+ return code.toString();
+ }
+
+ /**
+ * Generates k6 code to extract syncId from a UIDL response and increment
+ * clientId. Injected after each UIDL POST in the generated script.
+ *
+ * @param requestLabel
+ * human-readable label for error messages (e.g., "Request 10
+ * (POST ...)")
+ * @return the generated extraction code
+ */
+ private String generateUidlResponseExtractionCode(String requestLabel) {
+ StringBuilder code = new StringBuilder();
+ code.append(
+ " // Abort iteration and trigger test abort if UIDL response is invalid\n");
+ code.append(
+ " var syncIdMatch = response.body.match(/\"syncId\"\\s*:\\s*(-?\\d+)/)\n");
+ code.append(" if (!check(response, {\n");
+ code.append(" 'UIDL request succeeded': (r) => r.status === 200,\n");
+ code.append(
+ " 'no server error': (r) => !r.body.includes('\"appError\"'),\n");
+ code.append(
+ " 'no exception': (r) => !r.body.includes('Exception'),\n");
+ code.append(
+ " 'session is valid': (r) => !r.body.includes('Your session needs to be refreshed'),\n");
+ code.append(
+ " 'security key valid': (r) => !r.body.includes('Invalid security key'),\n");
+ code.append(" 'valid UIDL response': () => syncIdMatch !== null,\n");
+ code.append(" })) {\n");
+ code.append(" fail(`").append(escapeJsTemplate(requestLabel)).append(
+ ": UIDL failed (status ${response.status}): ${response.body.substring(0, 200)}`)\n");
+ code.append(" }\n\n");
+ code.append(
+ " // Update Vaadin sync counters from response (-1 means UNDEFINED_SYNC_ID, treat as 0)\n");
+ code.append(" syncId = Math.max(0, parseInt(syncIdMatch[1]))\n");
+ code.append(" clientId++\n");
+ code.append(
+ " // Extract grid item keys from response (used for select/edit operations)\n");
+ code.append(
+ " var found = response.body.match(/\"key\":\"[^\"]+\"/g)\n");
+ code.append(
+ " if (found) gridKeys = found.map(s => s.split('\"')[3])\n");
+ code.append("\n");
+ return code.toString();
+ }
+
+ /**
+ * Checks if a HAR entry is a Vaadin UIDL request.
+ *
+ * @param entry
+ * the HAR entry to check
+ * @return {@code true} if the entry is a UIDL POST request
+ */
+ private boolean isUidlRequest(HarEntry entry) {
+ return "POST".equalsIgnoreCase(entry.request().method())
+ && entry.request().url().contains("v-r=uidl");
+ }
+
+ /**
+ * Holds Vaadin session values detected from the recorded HAR file.
+ */
+ record VaadinSessionInfo(int initRequestIndex,
+ List allInitRequestIndices, String recordedJsessionId,
+ String recordedCsrfToken, String recordedUiId) {
+ /**
+ * Returns true if the given index is an init request (but not the first
+ * one).
+ */
+ boolean isSubsequentInitRequest(int index) {
+ return allInitRequestIndices.contains(index)
+ && index != initRequestIndex;
+ }
+ }
+
+ /**
+ * Calculates time delta in milliseconds between two requests.
+ *
+ * @param previous
+ * the previous HAR entry
+ * @param current
+ * the current HAR entry
+ * @return time delta in milliseconds, or 0 if unable to calculate
+ */
+ private long calculateDeltaMs(HarEntry previous, HarEntry current) {
+ try {
+ if (previous.startedDateTime() != null
+ && current.startedDateTime() != null) {
+ Instant prevTime = Instant.parse(previous.startedDateTime());
+ Instant currTime = Instant.parse(current.startedDateTime());
+ return Math.max(0,
+ currTime.toEpochMilli() - prevTime.toEpochMilli());
+ }
+ } catch (Exception e) {
+ // Ignore parse errors
+ }
+ return 0;
+ }
+
+ /**
+ * Truncates URL for display in comments.
+ *
+ * @param url
+ * the URL to truncate
+ * @return the truncated URL (max 80 characters)
+ */
+ private String truncateUrl(String url) {
+ if (url.length() > 80) {
+ return url.substring(0, 77) + "...";
+ }
+ return url;
+ }
+
+ /**
+ * Escapes a string for use in a JavaScript single-quoted string.
+ *
+ * @param str
+ * the string to escape
+ * @return the escaped string
+ */
+ private String escapeJs(String str) {
+ if (str == null) {
+ return "";
+ }
+ return str.replace("\\", "\\\\").replace("'", "\\'")
+ .replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t");
+ }
+
+ /**
+ * Escapes a string for use in a JavaScript template literal (backtick
+ * string). Does NOT escape ${} — the caller is responsible for inserting
+ * template expressions after calling this method.
+ *
+ * @param str
+ * the string to escape
+ * @return the escaped string
+ */
+ private String escapeJsTemplate(String str) {
+ if (str == null) {
+ return "";
+ }
+ return str.replace("\\", "\\\\").replace("`", "\\`")
+ .replace("${", "\\${").replace("\n", "\\n").replace("\r", "\\r")
+ .replace("\t", "\\t");
+ }
+
+ // ── CSV input data support ─────────────────────────────────────────
+
+ /**
+ * Injects SharedArray import and CSV loading code into the generated
+ * script. The CSV file contains recorded input values that can be expanded
+ * by the user to provide different data for each virtual user.
+ *
+ * @param scriptContent
+ * the generated k6 script content
+ * @param outputFile
+ * the output file path (used to derive the CSV file name)
+ * @return the modified script content with CSV loading injected
+ */
+ private String addCsvDataLoading(String scriptContent, Path outputFile) {
+ String csvFileName = deriveCsvPath(outputFile).getFileName().toString();
+
+ // Add SharedArray import after existing imports
+ scriptContent = scriptContent.replace("import { sleep } from 'k6'",
+ "import { sleep } from 'k6'\nimport { SharedArray } from 'k6/data'");
+
+ // Add CSV loading code and input row selection
+ String csvLoadBlock = "\n"
+ + "// Input test data from CSV — add rows to " + csvFileName
+ + " for per-VU variation\n"
+ + "const inputData = new SharedArray('input data', function () {\n"
+ + " const lines = open('./" + csvFileName
+ + "').split('\\n').filter(l => l.trim())\n"
+ + " const headers = lines[0].split(',')\n"
+ + " return lines.slice(1).filter(l => l.trim()).map(line => {\n"
+ + " const values = line.split(',')\n"
+ + " const obj = {}\n"
+ + " headers.forEach((h, i) => obj[h.trim()] = (values[i] || '').trim())\n"
+ + " return obj\n" + " })\n" + "})\n";
+
+ String inputRowSelection = " // Pick a random row from the input data CSV\n"
+ + " const inputRow = inputData[Math.floor(Math.random() * inputData.length)]\n\n";
+
+ scriptContent = scriptContent.replace("export default function () {\n",
+ csvLoadBlock + "\nexport default function () {\n"
+ + inputRowSelection);
+
+ return scriptContent;
+ }
+
+ /**
+ * Derives the CSV data file path from the k6 output file path. Strips
+ * "-generated" suffix if present so both generated and refactored scripts
+ * reference the same CSV file.
+ *
+ * @param outputFile
+ * the k6 output file path
+ * @return the derived CSV file path
+ */
+ private Path deriveCsvPath(Path outputFile) {
+ String fileName = outputFile.getFileName().toString();
+ String csvName;
+ if (fileName.endsWith("-generated.js")) {
+ csvName = fileName.replace("-generated.js", "-data.csv");
+ } else {
+ csvName = fileName.replace(".js", "-data.csv");
+ }
+ return outputFile.resolveSibling(csvName);
+ }
+
+ /**
+ * Writes the collected input values to a CSV file. The first row contains
+ * column headers (input_1, input_2, ...). The second row contains the
+ * values recorded during the TestBench test. Users can add more rows to
+ * provide different data for each virtual user; if only the recorded row is
+ * present it will be used for all VUs.
+ *
+ * @param csvFile
+ * the CSV file path to write
+ * @throws IOException
+ * if writing fails
+ */
+ private void writeCsvFile(Path csvFile) throws IOException {
+ StringBuilder csv = new StringBuilder();
+ // Header row
+ for (int i = 0; i < collectedInputValues.size(); i++) {
+ if (i > 0)
+ csv.append(',');
+ csv.append("input_").append(i + 1);
+ }
+ csv.append('\n');
+ // Data row with recorded values
+ for (int i = 0; i < collectedInputValues.size(); i++) {
+ if (i > 0)
+ csv.append(',');
+ csv.append(escapeCsvValue(collectedInputValues.get(i)));
+ }
+ csv.append('\n');
+ Files.writeString(csvFile, csv.toString());
+ }
+
+ /**
+ * Escapes a value for CSV output (RFC 4180). Wraps in double quotes if the
+ * value contains commas, quotes, or newlines.
+ *
+ * @param value
+ * the value to escape
+ * @return the escaped CSV value
+ */
+ private static String escapeCsvValue(String value) {
+ if (value.contains(",") || value.contains("\"") || value.contains("\n")
+ || value.contains("\r")) {
+ return "\"" + value.replace("\"", "\"\"") + "\"";
+ }
+ return value;
+ }
+
+ // HAR format DTOs using Java records
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ record HarFile(HarLog log) {
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ record HarLog(String version, HarCreator creator, List entries,
+ List pages) {
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ record HarCreator(String name, String version) {
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ record HarPage(String startedDateTime, String id, String title) {
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ record HarEntry(String startedDateTime, Double time, HarRequest request,
+ HarResponse response) {
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ record HarRequest(String method, String url, String httpVersion,
+ List headers, List cookies,
+ HarPostData postData) {
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ record HarResponse(Integer status, String statusText,
+ List headers, HarContent content) {
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ record HarHeader(String name, String value) {
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ record HarCookie(String name, String value) {
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ record HarPostData(String mimeType, String text) {
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ record HarContent(Long size, String mimeType, String text) {
+ }
+}
diff --git a/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/K6ScenarioCombiner.java b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/K6ScenarioCombiner.java
new file mode 100644
index 000000000..a9650ea7c
--- /dev/null
+++ b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/K6ScenarioCombiner.java
@@ -0,0 +1,325 @@
+/**
+ * Copyright (C) 2000-2026 Vaadin Ltd
+ *
+ * This program is available under Vaadin Commercial License and Service Terms.
+ *
+ * See for the full
+ * license.
+ */
+package com.vaadin.testbench.loadtest.util;
+
+import java.io.IOException;
+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.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Combines multiple k6 test files into a single test with weighted scenarios.
+ * Uses k6's built-in scenario feature to run different user workflows in
+ * parallel with configurable weights (percentage of VUs assigned to each
+ * scenario).
+ */
+public class K6ScenarioCombiner {
+
+ /**
+ * Creates a new K6ScenarioCombiner instance.
+ */
+ public K6ScenarioCombiner() {
+ }
+
+ /**
+ * Represents a scenario with its source file and weight.
+ */
+ public record ScenarioConfig(String name, Path testFile, int weight) {
+ }
+
+ /**
+ * Combines multiple k6 test files into a single test with weighted
+ * scenarios using default thresholds.
+ *
+ * @param scenarios
+ * list of scenario configurations with weights
+ * @param outputFile
+ * path for the combined output file
+ * @param totalVus
+ * total number of virtual users to distribute
+ * @param duration
+ * test duration (e.g., "30s", "1m")
+ * @throws IOException
+ * if file operations fail
+ */
+ public void combine(List scenarios, Path outputFile,
+ int totalVus, String duration) throws IOException {
+ combine(scenarios, outputFile, totalVus, duration,
+ ThresholdConfig.DEFAULT);
+ }
+
+ /**
+ * Combines multiple k6 test files into a single test with weighted
+ * scenarios.
+ *
+ * @param scenarios
+ * list of scenario configurations with weights
+ * @param outputFile
+ * path for the combined output file
+ * @param totalVus
+ * total number of virtual users to distribute
+ * @param duration
+ * test duration (e.g., "30s", "1m")
+ * @param thresholdConfig
+ * threshold configuration for the combined script
+ * @throws IOException
+ * if file operations fail
+ */
+ public void combine(List scenarios, Path outputFile,
+ int totalVus, String duration, ThresholdConfig thresholdConfig)
+ throws IOException {
+
+ StringBuilder sb = new StringBuilder();
+
+ // Pre-pass: extract SharedArray blocks from scenarios that use CSV
+ // input data.
+ // Each scenario gets a uniquely-named variable (e.g.
+ // crudExampleInputData)
+ // so multiple scenarios with CSV data don't collide.
+ Map sharedArrayBlocks = new LinkedHashMap<>();
+ for (ScenarioConfig config : scenarios) {
+ String content = Files.readString(config.testFile());
+ String block = extractSharedArrayBlock(content);
+ if (block != null) {
+ // Rename inputData → {name}InputData and SharedArray label
+ String renamed = block
+ .replace("inputData", config.name() + "InputData")
+ .replace("'input data'",
+ "'" + config.name() + " input data'");
+ sharedArrayBlocks.put(config.name(), renamed);
+ }
+ }
+ boolean needsSharedArray = !sharedArrayBlocks.isEmpty();
+
+ // Header
+ sb.append("// Combined k6 test with weighted scenarios\n");
+ sb.append("// Auto-generated by testbench-converter-plugin\n\n");
+ sb.append("import http from 'k6/http'\n");
+ sb.append("import { sleep, check, fail } from 'k6'\n");
+ if (needsSharedArray) {
+ sb.append("import { SharedArray } from 'k6/data'\n");
+ }
+ sb.append(
+ "import {extractJSessionId, getVaadinPushId, getVaadinSecurityKey, getVaadinUiId} from '../utils/vaadin-k6-helpers.js'\n\n");
+
+ // Configuration variables
+ sb.append(
+ "// Server configuration - can be overridden with: k6 run -e APP_IP=192.168.1.100 -e APP_PORT=8081 script.js\n");
+ sb.append("const APP_IP = __ENV.APP_IP || 'localhost';\n");
+ sb.append("const APP_PORT = __ENV.APP_PORT || '8080';\n");
+ sb.append("const BASE_URL = `http://${APP_IP}:${APP_PORT}`;\n\n");
+
+ // Add SharedArray blocks at module scope (must be in init context for
+ // k6)
+ for (Map.Entry entry : sharedArrayBlocks.entrySet()) {
+ sb.append(entry.getValue()).append("\n\n");
+ }
+
+ // Calculate VUs for each scenario based on weights
+ int totalWeight = scenarios.stream().mapToInt(ScenarioConfig::weight)
+ .sum();
+
+ // Generate options with scenarios and configurable thresholds
+ sb.append("export const options = {\n");
+ sb.append(thresholdConfig.toK6ThresholdsBlock());
+ sb.append(" scenarios: {\n");
+
+ for (int i = 0; i < scenarios.size(); i++) {
+ ScenarioConfig config = scenarios.get(i);
+ int vusForScenario = Math.max(1,
+ (config.weight() * totalVus) / totalWeight);
+
+ sb.append(" ").append(config.name()).append(": {\n");
+ sb.append(" executor: 'constant-vus',\n");
+ sb.append(" vus: ").append(vusForScenario).append(",\n");
+ sb.append(" duration: '").append(duration).append("',\n");
+ sb.append(" exec: '").append(config.name())
+ .append("Scenario',\n");
+ sb.append(" }");
+ if (i < scenarios.size() - 1) {
+ sb.append(",");
+ }
+ sb.append("\n");
+ }
+
+ sb.append(" },\n");
+ sb.append("};\n\n");
+
+ // Extract and add each scenario function
+ for (ScenarioConfig config : scenarios) {
+ String scenarioCode = extractScenarioFunction(config.testFile,
+ config.name(),
+ sharedArrayBlocks.containsKey(config.name()));
+ sb.append(scenarioCode).append("\n\n");
+ }
+
+ // Write combined file
+ Files.createDirectories(outputFile.getParent());
+ Files.writeString(outputFile, sb.toString());
+ }
+
+ /**
+ * Extracts the default function body from a k6 test and wraps it as a named
+ * function.
+ *
+ * @param testFile
+ * the scenario test file
+ * @param scenarioName
+ * the scenario name (used for the exported function name)
+ * @param hasCsvData
+ * if true, renames inputData references to
+ * {scenarioName}InputData
+ * @return the extracted and renamed function as a string
+ * @throws IOException
+ * if reading the test file fails
+ */
+ private String extractScenarioFunction(Path testFile, String scenarioName,
+ boolean hasCsvData) throws IOException {
+ String content = Files.readString(testFile);
+
+ // Find the default function body
+ Pattern pattern = Pattern.compile(
+ "export\\s+default\\s+function\\s*\\([^)]*\\)\\s*\\{",
+ Pattern.MULTILINE);
+
+ Matcher matcher = pattern.matcher(content);
+ if (!matcher.find()) {
+ throw new IOException(
+ "Could not find 'export default function' in " + testFile);
+ }
+
+ int functionStart = matcher.end();
+ int braceCount = 1;
+ int functionEnd = functionStart;
+
+ // Find matching closing brace
+ for (int i = functionStart; i < content.length()
+ && braceCount > 0; i++) {
+ char c = content.charAt(i);
+ if (c == '{')
+ braceCount++;
+ else if (c == '}')
+ braceCount--;
+ functionEnd = i;
+ }
+
+ String functionBody = content.substring(functionStart, functionEnd);
+
+ // Rename inputData references to scenario-specific name for combined
+ // scripts
+ if (hasCsvData) {
+ functionBody = functionBody.replace("inputData",
+ scenarioName + "InputData");
+ }
+
+ // Create named export function
+ StringBuilder sb = new StringBuilder();
+ sb.append("// Scenario: ").append(scenarioName).append("\n");
+ sb.append(
+ "// Weight-based VU distribution - runs in parallel with other scenarios\n");
+ sb.append("export function ").append(scenarioName)
+ .append("Scenario() {");
+ sb.append(functionBody);
+ sb.append("}");
+
+ return sb.toString();
+ }
+
+ /**
+ * Extracts a SharedArray block from a k6 script, if present. Returns the
+ * full block including any preceding comment line, or null if not found.
+ *
+ * @param content
+ * the k6 script content
+ * @return the SharedArray block, or {@code null} if not found
+ */
+ private String extractSharedArrayBlock(String content) {
+ String marker = "const inputData = new SharedArray(";
+ int blockStart = content.indexOf(marker);
+ if (blockStart < 0) {
+ return null;
+ }
+
+ // Include the preceding comment line (e.g., "// Input test data from
+ // CSV ...")
+ int lineStart = content.lastIndexOf('\n', blockStart - 1);
+ if (lineStart >= 0) {
+ String precedingLine = content.substring(lineStart + 1, blockStart)
+ .trim();
+ if (precedingLine.startsWith("//")) {
+ blockStart = lineStart + 1;
+ }
+ }
+
+ // Find the end of the SharedArray(...) call using parenthesis counting
+ int parenSearch = content.indexOf("SharedArray(", blockStart);
+ int i = parenSearch + "SharedArray(".length();
+ int parenCount = 1;
+ while (i < content.length() && parenCount > 0) {
+ char c = content.charAt(i);
+ if (c == '(')
+ parenCount++;
+ else if (c == ')')
+ parenCount--;
+ i++;
+ }
+
+ return content.substring(blockStart, i);
+ }
+
+ /**
+ * Creates scenario configurations from test files with equal weights.
+ *
+ * @param testFiles
+ * the test files to create scenarios for
+ * @return list of scenario configurations with evenly distributed weights
+ */
+ public static List equalWeights(List testFiles) {
+ int weight = 100 / testFiles.size();
+ List configs = new ArrayList<>();
+ for (Path file : testFiles) {
+ String name = fileToScenarioName(file);
+ configs.add(new ScenarioConfig(name, file, weight));
+ }
+ return configs;
+ }
+
+ /**
+ * Converts a file name to a valid JavaScript function name. E.g.,
+ * "hello-world.js" -> "helloWorld"
+ *
+ * @param file
+ * the test file path
+ * @return the camelCase scenario name
+ */
+ private static String fileToScenarioName(Path file) {
+ String name = file.getFileName().toString().replaceAll("\\.js$", "")
+ .replaceAll("-generated$", "");
+
+ // Convert kebab-case to camelCase
+ StringBuilder result = new StringBuilder();
+ boolean capitalizeNext = false;
+ for (char c : name.toCharArray()) {
+ if (c == '-' || c == '_') {
+ capitalizeNext = true;
+ } else if (capitalizeNext) {
+ result.append(Character.toUpperCase(c));
+ capitalizeNext = false;
+ } else {
+ result.append(c);
+ }
+ }
+ return result.toString();
+ }
+}
diff --git a/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/K6TestRefactorer.java b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/K6TestRefactorer.java
new file mode 100644
index 000000000..444409923
--- /dev/null
+++ b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/K6TestRefactorer.java
@@ -0,0 +1,666 @@
+/**
+ * Copyright (C) 2000-2026 Vaadin Ltd
+ *
+ * This program is available under Vaadin Commercial License and Service Terms.
+ *
+ * See for the full
+ * license.
+ */
+package com.vaadin.testbench.loadtest.util;
+
+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.logging.Logger;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Refactors k6 test scripts for Vaadin applications.
+ *
+ * Transforms recorded k6 tests by: - Adding Vaadin helper imports - Replacing
+ * hardcoded IPs with configurable variables - Extracting JSESSIONID dynamically
+ * from responses - Extracting csrfToken, uiId, pushId dynamically - Converting
+ * static cookies to dynamic variables - Adding realistic think time between
+ * user actions
+ */
+public class K6TestRefactorer {
+
+ private static final String HELPER_IMPORT = "import {extractJSessionId, getVaadinPushId, getVaadinSecurityKey, getVaadinUiId} from '../utils/vaadin-k6-helpers.js'";
+
+ private static final String CONFIG_VARS = """
+
+ // Server configuration - can be overridden with: k6 run -e APP_IP=192.168.1.100 -e APP_PORT=8081 script.js
+ const APP_IP = __ENV.APP_IP || 'localhost';
+ const APP_PORT = __ENV.APP_PORT || '8080';
+ const BASE_URL = `http://${APP_IP}:${APP_PORT}`;
+ """;
+
+ private static final String JSESSION_EXTRACT = """
+
+
+ // Extract JSESSIONID from Set-Cookie header
+ const jsessionId = extractJSessionId(response);""";
+
+ private static final String VAADIN_EXTRACT = """
+
+
+ //Request for csrf toke and push id
+ //extra csrf token from Vaadin-Security-Key and Vaadin-Push-ID from response
+ const csrfToken = getVaadinSecurityKey(response.body);
+ const uiId = getVaadinUiId(response.body);
+ const pushId = getVaadinPushId(response.body);""";
+
+ // Patterns for detection
+ private static final Pattern SERVER_PATTERN = Pattern.compile(
+ "http://(localhost|\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}):(\\d+)");
+ private static final Pattern JSESSION_PATTERN = Pattern
+ .compile("'[Cc]ookie': 'JSESSIONID=([A-F0-9]+)'");
+ private static final Pattern CSRF_PATTERN = Pattern
+ .compile("\"csrfToken\":\"([a-f0-9-]{36})\"");
+
+ private static final Logger log = Logger
+ .getLogger(K6TestRefactorer.class.getName());
+ private final ThinkTimeConfig thinkTimeConfig;
+
+ /**
+ * Configuration for think time delays.
+ *
+ * @param enabled
+ * whether to insert think time delays
+ * @param pageReadDelay
+ * base delay after page load in seconds (0 to disable)
+ * @param interactionDelay
+ * base delay after user interaction in seconds (0 to disable)
+ * @param actionBlockThresholdMs
+ * requests within this threshold are grouped as one "action
+ * block" and only receive think time after the block completes
+ * (default: 100ms)
+ * @param existingDelayThresholdMs
+ * if HAR already has a gap larger than this, don't add more
+ * delay (default: 500ms)
+ */
+ public record ThinkTimeConfig(boolean enabled, double pageReadDelay,
+ double interactionDelay, long actionBlockThresholdMs,
+ long existingDelayThresholdMs) {
+
+ /** Default configuration with realistic think times enabled. */
+ public static final ThinkTimeConfig DEFAULT = new ThinkTimeConfig(true,
+ 2.0, 0.5, 100, 500);
+
+ /** Configuration with think times disabled for maximum throughput. */
+ public static final ThinkTimeConfig DISABLED = new ThinkTimeConfig(
+ false, 0, 0, 100, 500);
+
+ /**
+ * Simplified constructor for backwards compatibility.
+ */
+ public ThinkTimeConfig(boolean enabled, double pageReadDelay,
+ double interactionDelay) {
+ this(enabled, pageReadDelay, interactionDelay, 100, 500);
+ }
+ }
+
+ /**
+ * Creates a refactorer with default think time configuration.
+ */
+ public K6TestRefactorer() {
+ this(ThinkTimeConfig.DEFAULT);
+ }
+
+ /**
+ * Creates a refactorer with custom think time configuration.
+ *
+ * @param thinkTimeConfig
+ * think time configuration
+ */
+ public K6TestRefactorer(ThinkTimeConfig thinkTimeConfig) {
+ this.thinkTimeConfig = thinkTimeConfig;
+ }
+
+ /**
+ * Refactors a k6 test file for Vaadin compatibility.
+ *
+ * @param inputFile
+ * the input k6 test file
+ * @param outputFile
+ * the output refactored test file
+ * @throws IOException
+ * if reading or writing fails
+ */
+ public void refactor(Path inputFile, Path outputFile) throws IOException {
+ log.info("Refactoring k6 test for Vaadin...");
+
+ String content = Files.readString(inputFile);
+ String refactored = refactorContent(content);
+
+ Files.writeString(outputFile, refactored);
+
+ log.info("Refactored k6 test written to: " + outputFile);
+ }
+
+ /**
+ * Refactors the content of a k6 test script.
+ *
+ * @param content
+ * the original script content
+ * @return the refactored script content
+ */
+ public String refactorContent(String content) {
+ // 1. Detect the server IP in the script
+ ServerInfo server = detectServerIp(content);
+ if (server == null) {
+ log.warning("Could not detect server IP in the script");
+ return content;
+ }
+
+ String serverUrl = "http://" + server.ip() + ":" + server.port();
+ String hostHeader = server.ip() + ":" + server.port();
+
+ log.info(" Detected server: " + serverUrl);
+
+ // 2. Find JSESSIONID value to replace later
+ Matcher jsessionMatcher = JSESSION_PATTERN.matcher(content);
+ String jsessionId = jsessionMatcher.find() ? jsessionMatcher.group(1)
+ : null;
+
+ // 3. Find csrfToken value to replace later
+ Matcher csrfMatcher = CSRF_PATTERN.matcher(content);
+ String csrfToken = csrfMatcher.find() ? csrfMatcher.group(1) : null;
+
+ log.info(" JSESSIONID found: " + (jsessionId != null ? "yes" : "no"));
+ log.info(" csrfToken found: " + (csrfToken != null ? "yes" : "no"));
+
+ // 4. Add helper import after 'import http from k6/http'
+ content = content.replaceFirst("(import http from ['\"]k6/http['\"])",
+ "$1\n" + HELPER_IMPORT);
+
+ // 5. Add configuration variables before 'export default function'
+ if (!content.contains("const BASE_URL")) {
+ content = content.replaceFirst("(export default function)",
+ Matcher.quoteReplacement(CONFIG_VARS) + "\n$1");
+ }
+
+ // 6. Replace all hardcoded URLs with BASE_URL (both single-quoted and
+ // backtick)
+ String escapedServerUrl = Pattern.quote(serverUrl);
+ // Single-quoted URLs: 'http://host:port/...' → `${BASE_URL}/...`
+ content = content.replaceAll("'" + escapedServerUrl + "(/[^']*)?'",
+ Matcher.quoteReplacement("`${BASE_URL}") + "$1`");
+ // Backtick URLs: `http://host:port/...` → `${BASE_URL}/...`
+ // (these already contain template expressions like ${uiId})
+ content = content.replaceAll("`" + escapedServerUrl,
+ Matcher.quoteReplacement("`${BASE_URL}"));
+
+ // 7. Replace host headers
+ String escapedHostHeader = Pattern.quote(hostHeader);
+ content = content.replaceAll("'host': '" + escapedHostHeader + "'",
+ Matcher.quoteReplacement("'host': `${APP_IP}:${APP_PORT}`"));
+
+ // 8. Replace origin headers
+ content = content.replaceAll("'[Oo]rigin': '" + escapedServerUrl + "'",
+ Matcher.quoteReplacement("'Origin': `${BASE_URL}`"));
+
+ // 9. Replace referer headers (both single-quoted and backtick)
+ content = content.replaceAll(
+ "'[Rr]eferer': '" + escapedServerUrl + "(/[^']*)?'",
+ Matcher.quoteReplacement("'Referer': `${BASE_URL}") + "$1`");
+ content = content.replaceAll("'[Rr]eferer': `" + escapedServerUrl,
+ Matcher.quoteReplacement("'Referer': `${BASE_URL}"));
+
+ // 10. Replace JSESSIONID cookies (handles both 'cookie' and 'Cookie'
+ // headers)
+ if (jsessionId != null) {
+ content = content.replaceAll(
+ "'([Cc]ookie)': 'JSESSIONID=" + Pattern.quote(jsessionId)
+ + "'",
+ "'$1': " + Matcher
+ .quoteReplacement("`JSESSIONID=${jsessionId}`"));
+ }
+
+ // 11. Replace csrfToken in POST bodies
+ if (csrfToken != null) {
+ content = content.replaceAll(
+ "\"csrfToken\":\"" + Pattern.quote(csrfToken) + "\"",
+ Matcher.quoteReplacement("\"csrfToken\":\"${csrfToken}\""));
+
+ // Convert POST body strings from single quotes to template literals
+ // when they contain ${
+ content = convertPostBodiesToTemplateLiterals(content);
+ }
+
+ // 12. Insert JSESSIONID extraction after first GET to the app
+ // Skip if already extracted by converter (let jsessionId) or refactorer
+ // (const jsessionId)
+ if (!content.contains("const jsessionId = extractJSessionId")
+ && !content.contains("let jsessionId")) {
+ content = insertJsessionExtraction(content);
+ }
+
+ // 13. Insert Vaadin token extraction after v-r=init request
+ // Skip if already extracted by converter (let uiId) or refactorer
+ // (const uiId)
+ if (!content.contains("const csrfToken = getVaadinSecurityKey")
+ && !content.contains("let uiId")) {
+ content = insertVaadinExtraction(content);
+ }
+
+ // 14. Insert realistic think times between user actions
+ content = insertThinkTimes(content);
+
+ return content;
+ }
+
+ // Pattern to extract HAR timing delta from comments
+ private static final Pattern HAR_DELTA_PATTERN = Pattern
+ .compile("// HAR_DELTA_MS: (\\d+)");
+
+ /**
+ * Inserts realistic think time delays at user action boundaries.
+ *
+ * The algorithm: 1. Parses HAR timing deltas from embedded comments 2.
+ * Groups requests with gaps < actionBlockThreshold as "action blocks" 3.
+ * Adds think time only at action block boundaries: - After v-r=init: page
+ * read delay (user reading the loaded page) - After v-r=uidl blocks:
+ * interaction delay (user thinking before next action) 4. Skips adding
+ * delay if HAR already has a large gap (existing user delay in TestBench)
+ *
+ * @param content
+ * the k6 script content
+ * @return the modified content with think time delays inserted
+ */
+ private String insertThinkTimes(String content) {
+ if (!thinkTimeConfig.enabled()) {
+ log.info(" Think times: disabled");
+ return content;
+ }
+
+ List lines = new ArrayList<>(List.of(content.split("\n")));
+
+ // First pass: collect request info with timing
+ List requests = parseRequestsWithTiming(lines);
+
+ if (requests.isEmpty()) {
+ log.info(" Think times: no requests found");
+ return content;
+ }
+
+ // Second pass: identify user actions and block boundaries, insert
+ // delays
+ List insertionPoints = new ArrayList<>();
+ List delaysToInsert = new ArrayList<>();
+
+ int pageReadDelays = 0;
+ int interactionDelays = 0;
+ int skippedDueToExistingDelay = 0;
+
+ // Track current action block for init detection
+ boolean blockContainsInit = false;
+ int initEndLineIndex = -1;
+
+ for (int i = 0; i < requests.size(); i++) {
+ RequestInfo req = requests.get(i);
+ RequestInfo nextReq = (i + 1 < requests.size())
+ ? requests.get(i + 1)
+ : null;
+
+ // Update block tracking for init
+ if (req.isInit) {
+ blockContainsInit = true;
+ initEndLineIndex = req.endLineIndex;
+ }
+
+ // Check timing to next request
+ long nextDeltaMs = (nextReq != null) ? nextReq.harDeltaMs
+ : Long.MAX_VALUE;
+ boolean isBlockBoundary = (nextReq == null
+ || nextDeltaMs > thinkTimeConfig.actionBlockThresholdMs());
+ boolean hasExistingDelay = nextDeltaMs >= thinkTimeConfig
+ .existingDelayThresholdMs();
+
+ // Case 1: Block containing init ends - add page read delay
+ if (isBlockBoundary && blockContainsInit
+ && thinkTimeConfig.pageReadDelay() > 0) {
+ if (hasExistingDelay) {
+ skippedDueToExistingDelay++;
+ } else {
+ // Insert after Vaadin extraction if it exists
+ int insertAt = req.endLineIndex;
+ if (initEndLineIndex > 0
+ && initEndLineIndex + 1 < lines.size()
+ && lines.get(initEndLineIndex + 1)
+ .contains("getVaadinSecurityKey")) {
+ insertAt = initEndLineIndex + 5;
+ }
+ insertionPoints.add(insertAt);
+ delaysToInsert
+ .add(generateDelayCode("user reading the page",
+ thinkTimeConfig.pageReadDelay(),
+ thinkTimeConfig.pageReadDelay() * 1.5));
+ pageReadDelays++;
+ }
+ // Reset init tracking
+ blockContainsInit = false;
+ initEndLineIndex = -1;
+ continue; // Don't also add interaction delay
+ }
+
+ // Case 2: User action detected (click, text input) - add
+ // interaction delay
+ if (req.isUserAction && thinkTimeConfig.interactionDelay() > 0) {
+ if (hasExistingDelay) {
+ skippedDueToExistingDelay++;
+ } else {
+ insertionPoints.add(req.endLineIndex);
+ delaysToInsert.add(generateDelayCode(
+ "user thinking before next action",
+ thinkTimeConfig.interactionDelay(),
+ thinkTimeConfig.interactionDelay() * 3));
+ interactionDelays++;
+ }
+ }
+
+ // Reset init tracking at block boundaries
+ if (isBlockBoundary) {
+ blockContainsInit = false;
+ initEndLineIndex = -1;
+ }
+ }
+
+ // Insert delays in reverse order to maintain line indices
+ for (int i = insertionPoints.size() - 1; i >= 0; i--) {
+ int insertAt = insertionPoints.get(i);
+ if (insertAt < lines.size()) {
+ lines.add(insertAt + 1, delaysToInsert.get(i));
+ }
+ }
+
+ log.info(" Think times: " + (pageReadDelays + interactionDelays)
+ + " delays inserted " + "(page: " + pageReadDelays + " @ "
+ + thinkTimeConfig.pageReadDelay() + "s base, " + "interaction: "
+ + interactionDelays + " @ " + thinkTimeConfig.interactionDelay()
+ + "s base)");
+ if (skippedDueToExistingDelay > 0) {
+ log.info(" Think times: " + skippedDueToExistingDelay
+ + " delay(s) skipped (HAR already has gaps >= "
+ + thinkTimeConfig.existingDelayThresholdMs() + "ms)");
+ }
+
+ return String.join("\n", lines);
+ }
+
+ // Patterns to detect user actions in UIDL content
+ private static final Pattern CLICK_EVENT_PATTERN = Pattern
+ .compile("\"event\":\"click\"");
+ private static final Pattern CHANGE_EVENT_PATTERN = Pattern
+ .compile("\"event\":\"change\"");
+ private static final Pattern VALUE_CHANGE_WITH_DATA = Pattern
+ .compile("\"value\":\"[^\"]+\"");
+
+ /**
+ * Parses request information including HAR timing from the generated
+ * script.
+ *
+ * @param lines
+ * the script lines to parse
+ * @return list of parsed request info objects
+ */
+ private List parseRequestsWithTiming(List lines) {
+ List requests = new ArrayList<>();
+ long currentHarDelta = 0;
+ boolean inRequest = false;
+ boolean isUidl = false;
+ boolean isInit = false;
+ boolean isUserAction = false;
+ int braceCount = 0;
+ int requestStartLine = -1;
+
+ for (int i = 0; i < lines.size(); i++) {
+ String line = lines.get(i);
+
+ // Parse HAR timing delta
+ Matcher deltaMatcher = HAR_DELTA_PATTERN.matcher(line);
+ if (deltaMatcher.find()) {
+ currentHarDelta = Long.parseLong(deltaMatcher.group(1));
+ }
+
+ // Detect start of request
+ if (line.contains("v-r=init") && !inRequest) {
+ inRequest = true;
+ isInit = true;
+ isUidl = false;
+ isUserAction = false;
+ braceCount = 0;
+ requestStartLine = i;
+ } else if (line.contains("v-r=uidl") && !inRequest) {
+ inRequest = true;
+ isUidl = true;
+ isInit = false;
+ isUserAction = false;
+ braceCount = 0;
+ requestStartLine = i;
+ } else if ((line.contains("http.get(")
+ || line.contains("http.post(")) && !inRequest) {
+ inRequest = true;
+ isInit = false;
+ isUidl = false;
+ isUserAction = false;
+ braceCount = 0;
+ requestStartLine = i;
+ }
+
+ if (inRequest) {
+ braceCount += countOccurrences(line, '{');
+ braceCount -= countOccurrences(line, '}');
+
+ // Check for user action patterns in UIDL requests
+ if (isUidl) {
+ if (CLICK_EVENT_PATTERN.matcher(line).find()) {
+ isUserAction = true;
+ }
+ // Change event with actual value = user typed something
+ if (CHANGE_EVENT_PATTERN.matcher(line).find()
+ && VALUE_CHANGE_WITH_DATA.matcher(line).find()) {
+ isUserAction = true;
+ }
+ }
+
+ // End of request
+ if (braceCount == 0 && line.contains(")")) {
+ requests.add(new RequestInfo(i, currentHarDelta, isInit,
+ isUidl, isUserAction));
+ inRequest = false;
+ isInit = false;
+ isUidl = false;
+ isUserAction = false;
+ currentHarDelta = 0;
+ }
+ }
+ }
+
+ return requests;
+ }
+
+ /**
+ * Information about a parsed request.
+ *
+ * @param endLineIndex
+ * line index where the request ends
+ * @param harDeltaMs
+ * time delta from previous request in ms
+ * @param isInit
+ * true if this is a v-r=init request
+ * @param isUidl
+ * true if this is a v-r=uidl request
+ * @param isUserAction
+ * true if this UIDL contains a user action (click, text input)
+ */
+ private record RequestInfo(int endLineIndex, long harDeltaMs,
+ boolean isInit, boolean isUidl, boolean isUserAction) {
+ }
+
+ /**
+ * Generates JavaScript code for a sleep delay with randomness.
+ *
+ * @param comment
+ * the comment describing the delay type
+ * @param baseDelay
+ * the base delay in seconds
+ * @param randomRange
+ * the random range added to the base delay
+ * @return the generated JavaScript sleep code
+ */
+ private String generateDelayCode(String comment, double baseDelay,
+ double randomRange) {
+ // Use Locale.US to ensure period decimal separator in generated
+ // JavaScript
+ return String.format(java.util.Locale.US, """
+
+ // Think time: %s
+ sleep(%.1f + Math.random() * %.1f);""", comment, baseDelay,
+ randomRange);
+ }
+
+ /**
+ * Detects server host and port in the script.
+ *
+ * @param content
+ * the k6 script content
+ * @return the detected server info, or {@code null} if not found
+ */
+ private ServerInfo detectServerIp(String content) {
+ Matcher matcher = SERVER_PATTERN.matcher(content);
+ if (matcher.find()) {
+ return new ServerInfo(matcher.group(1), matcher.group(2));
+ }
+ return null;
+ }
+
+ /**
+ * Converts POST body strings from single quotes to template literals when
+ * they contain ${.
+ *
+ * @param content
+ * the k6 script content
+ * @return the modified content with template literals
+ */
+ private String convertPostBodiesToTemplateLiterals(String content) {
+ // Match: http.post(..., '{...${csrfToken}...}', ...)
+ Pattern postPattern = Pattern.compile(
+ "(http\\.post\\(\\s*`[^`]+`,\\s*)'(\\{[^']*\\$\\{csrfToken\\}[^']*\\})'",
+ Pattern.MULTILINE);
+ Matcher matcher = postPattern.matcher(content);
+ StringBuffer sb = new StringBuffer();
+ while (matcher.find()) {
+ String replacement = matcher.group(1) + "`" + matcher.group(2)
+ + "`";
+ matcher.appendReplacement(sb,
+ Matcher.quoteReplacement(replacement));
+ }
+ matcher.appendTail(sb);
+ return sb.toString();
+ }
+
+ /**
+ * Inserts JSESSIONID extraction after first GET to the app.
+ *
+ * @param content
+ * the k6 script content
+ * @return the modified content with JSESSIONID extraction
+ */
+ private String insertJsessionExtraction(String content) {
+ List lines = new ArrayList<>(List.of(content.split("\n")));
+ boolean foundFirstRequest = false;
+ boolean inFirstAppRequest = false;
+ int braceCount = 0;
+ int insertLineIndex = -1;
+
+ for (int i = 0; i < lines.size(); i++) {
+ String line = lines.get(i);
+
+ // Look for "// Request 1:" comment that marks the first request
+ if (line.contains("// Request 1:") && !foundFirstRequest) {
+ foundFirstRequest = true;
+ }
+
+ // Once we found the comment, look for http.get on subsequent lines
+ if (foundFirstRequest && !inFirstAppRequest
+ && line.contains("http.get(")) {
+ inFirstAppRequest = true;
+ braceCount = 0;
+ }
+
+ if (inFirstAppRequest) {
+ braceCount += countOccurrences(line, '{');
+ braceCount -= countOccurrences(line, '}');
+
+ if (braceCount == 0 && line.contains(")")) {
+ insertLineIndex = i;
+ break;
+ }
+ }
+ }
+
+ if (insertLineIndex > 0) {
+ lines.add(insertLineIndex + 1, JSESSION_EXTRACT);
+ return String.join("\n", lines);
+ }
+
+ return content;
+ }
+
+ /**
+ * Inserts Vaadin token extraction after v-r=init request.
+ *
+ * @param content
+ * the k6 script content
+ * @return the modified content with Vaadin token extraction
+ */
+ private String insertVaadinExtraction(String content) {
+ List lines = new ArrayList<>(List.of(content.split("\n")));
+ boolean inInitRequest = false;
+ int braceCount = 0;
+ int insertLineIndex = -1;
+
+ for (int i = 0; i < lines.size(); i++) {
+ String line = lines.get(i);
+
+ if (line.contains("v-r=init") && !inInitRequest) {
+ inInitRequest = true;
+ braceCount = 0;
+ }
+
+ if (inInitRequest) {
+ braceCount += countOccurrences(line, '{');
+ braceCount -= countOccurrences(line, '}');
+
+ if (braceCount == 0 && line.contains(")")) {
+ insertLineIndex = i;
+ break;
+ }
+ }
+ }
+
+ if (insertLineIndex > 0) {
+ lines.add(insertLineIndex + 1, VAADIN_EXTRACT);
+ return String.join("\n", lines);
+ }
+
+ return content;
+ }
+
+ private int countOccurrences(String str, char c) {
+ int count = 0;
+ for (int i = 0; i < str.length(); i++) {
+ if (str.charAt(i) == c) {
+ count++;
+ }
+ }
+ return count;
+ }
+
+ private record ServerInfo(String ip, String port) {
+ }
+}
diff --git a/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/MetricsCollector.java b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/MetricsCollector.java
new file mode 100644
index 000000000..9f0a3c330
--- /dev/null
+++ b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/MetricsCollector.java
@@ -0,0 +1,367 @@
+/**
+ * Copyright (C) 2000-2026 Vaadin Ltd
+ *
+ * This program is available under Vaadin Commercial License and Service Terms.
+ *
+ * See for the full
+ * license.
+ */
+package com.vaadin.testbench.loadtest.util;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.logging.Logger;
+
+import com.vaadin.testbench.loadtest.util.ActuatorMetrics.MetricsSummary;
+
+/**
+ * Collects server metrics periodically during a load test.
+ *
+ * This collector runs in a background thread, fetching metrics from Spring Boot
+ * Actuator at regular intervals. After the test completes, the collected
+ * snapshots can be displayed as a time-series table showing how server metrics
+ * changed under load.
+ *
+ * Usage:
+ *
+ *
+ * MetricsCollector collector = new MetricsCollector(log, actuator, 10);
+ * collector.start();
+ * // ... run load test ...
+ * collector.stop();
+ * collector.printReport();
+ *
+ */
+public class MetricsCollector implements Runnable {
+
+ private static final Logger log = Logger
+ .getLogger(MetricsCollector.class.getName());
+ private final ActuatorMetrics actuator;
+ private final int intervalSeconds;
+ private final List snapshots = Collections
+ .synchronizedList(new ArrayList<>());
+ private final Instant startTime;
+ private volatile boolean running = true;
+ private Thread collectorThread;
+
+ /**
+ * Creates a new metrics collector.
+ *
+ * @param actuator
+ * actuator metrics client
+ * @param intervalSeconds
+ * interval between metric snapshots in seconds
+ */
+ public MetricsCollector(ActuatorMetrics actuator, int intervalSeconds) {
+ this.actuator = actuator;
+ this.intervalSeconds = intervalSeconds;
+ this.startTime = Instant.now();
+ }
+
+ /**
+ * Collects a baseline snapshot synchronously before load test starts. Call
+ * this before starting k6 to capture pre-test server state.
+ */
+ public void collectBaseline() {
+ collectSnapshot();
+ log.fine("Baseline metrics collected");
+ }
+
+ /**
+ * Starts the background collection thread.
+ */
+ public void start() {
+ collectorThread = new Thread(this, "metrics-collector");
+ collectorThread.setDaemon(true);
+ collectorThread.start();
+ log.fine("Metrics collector started (interval: " + intervalSeconds
+ + "s)");
+ }
+
+ /**
+ * Stops the collection thread and waits for it to finish.
+ */
+ public void stop() {
+ running = false;
+ if (collectorThread != null) {
+ collectorThread.interrupt();
+ try {
+ collectorThread.join(5000);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ log.fine("Metrics collector stopped (" + snapshots.size()
+ + " snapshots collected)");
+ }
+
+ @Override
+ public void run() {
+ // Note: baseline is collected separately via collectBaseline()
+ // Wait for the first interval before collecting again
+ while (running) {
+ try {
+ Thread.sleep(intervalSeconds * 1000L);
+ if (running) {
+ collectSnapshot();
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ break;
+ }
+ }
+ }
+
+ /**
+ * Collects a single metrics snapshot.
+ */
+ private void collectSnapshot() {
+ try {
+ actuator.fetchMetrics().ifPresent(metrics -> {
+ Duration elapsed = Duration.between(startTime, Instant.now());
+ snapshots.add(new TimestampedMetrics(elapsed, metrics));
+ });
+ } catch (Exception e) {
+ log.fine("Failed to collect metrics snapshot: " + e.getMessage());
+ }
+ }
+
+ /**
+ * Returns the collected snapshots.
+ *
+ * @return a copy of the collected snapshots list
+ */
+ public List getSnapshots() {
+ return new ArrayList<>(snapshots);
+ }
+
+ /**
+ * Prints a formatted report of collected metrics to stdout. Uses System.out
+ * directly for clean table formatting. Splits output into a system metrics
+ * table and a view counts table.
+ */
+ public void printReport() {
+ if (snapshots.isEmpty()) {
+ System.out.println(
+ "No metrics collected (actuator may not be available)");
+ return;
+ }
+
+ boolean hasVaadinMetrics = snapshots.stream()
+ .anyMatch(s -> s.metrics().vaadinActiveUis() != null);
+
+ List viewNames = snapshots.stream()
+ .filter(s -> s.metrics().viewCounts() != null)
+ .flatMap(s -> s.metrics().viewCounts().keySet().stream())
+ .distinct().sorted().toList();
+
+ MetricsSummary first = snapshots.get(0).metrics();
+ MetricsSummary last = snapshots.get(snapshots.size() - 1).metrics();
+
+ // Table 1: System metrics
+ System.out.println();
+ System.out.println("Server Metrics (via Spring Boot Actuator):");
+ printSystemMetricsTable(hasVaadinMetrics);
+
+ // Table 2: View counts (if available)
+ if (!viewNames.isEmpty()) {
+ System.out.println();
+ System.out.println("View Counts:");
+ printViewCountsTable(viewNames);
+ }
+
+ // Summary
+ if (snapshots.size() > 1) {
+ printSummary(first, last, viewNames);
+ }
+ }
+
+ /**
+ * Prints the system metrics table (CPU, memory, sessions, UIs).
+ */
+ private void printSystemMetricsTable(boolean hasVaadinMetrics) {
+ String border = hasVaadinMetrics
+ ? "+--------+--------+------------+------------+-----------+----------+--------+"
+ : "+--------+--------+------------+------------+-----------+----------+";
+ String header = hasVaadinMetrics
+ ? "| Time | CPU % | Heap Used | Heap Max | Non-Heap | Sessions | UIs |"
+ : "| Time | CPU % | Heap Used | Heap Max | Non-Heap | Sessions |";
+
+ System.out.println(border);
+ System.out.println(header);
+ System.out.println(border);
+
+ for (TimestampedMetrics snapshot : snapshots) {
+ MetricsSummary m = snapshot.metrics();
+ String time = formatDuration(snapshot.elapsed());
+ String cpu = m.processCpuPercent() != null
+ ? String.format(Locale.US, "%5.1f%%", m.processCpuPercent())
+ : " N/A";
+ String heapUsed = padLeft(m.formatBytes(m.heapUsedBytes()), 10);
+ String heapMax = padLeft(m.formatBytes(m.heapMaxBytes()), 10);
+ String nonHeap = padLeft(m.formatBytes(m.nonHeapUsedBytes()), 9);
+ String sessions = m.activeSessions() != null
+ ? String.format("%8d", m.activeSessions())
+ : " N/A";
+
+ StringBuilder row = new StringBuilder();
+ row.append(String.format("| %-6s | %6s | %s | %s | %s | %s |", time,
+ cpu, heapUsed, heapMax, nonHeap, sessions));
+
+ if (hasVaadinMetrics) {
+ String uis = m.vaadinActiveUis() != null
+ ? String.format("%6d", m.vaadinActiveUis())
+ : " N/A";
+ row.append(String.format(" %s |", uis));
+ }
+
+ System.out.println(row);
+ }
+
+ System.out.println(border);
+ }
+
+ /**
+ * Prints the view counts table with dynamically sized columns.
+ */
+ private void printViewCountsTable(List viewNames) {
+ // Calculate column widths: max of header length and formatted value
+ // length, min 6
+ List colWidths = new ArrayList<>();
+ for (String viewName : viewNames) {
+ int headerLen = viewName.length();
+ int maxValueLen = 0;
+ for (TimestampedMetrics snapshot : snapshots) {
+ Long count = snapshot.metrics().viewCounts() != null
+ ? snapshot.metrics().viewCounts().get(viewName)
+ : null;
+ if (count != null) {
+ maxValueLen = Math.max(maxValueLen,
+ String.format("%,d", count).length());
+ }
+ }
+ colWidths.add(Math.max(Math.max(headerLen, maxValueLen), 6));
+ }
+
+ // Build border, header, and rows using calculated widths
+ StringBuilder border = new StringBuilder("+--------");
+ StringBuilder header = new StringBuilder(
+ String.format("| %-6s ", "Time"));
+ for (int i = 0; i < viewNames.size(); i++) {
+ int w = colWidths.get(i);
+ border.append("+-").append("-".repeat(w)).append("-");
+ header.append(String.format("| %-" + w + "s ", viewNames.get(i)));
+ }
+ border.append("+");
+ header.append("|");
+
+ System.out.println(border);
+ System.out.println(header);
+ System.out.println(border);
+
+ for (TimestampedMetrics snapshot : snapshots) {
+ MetricsSummary m = snapshot.metrics();
+ StringBuilder row = new StringBuilder(String.format("| %-6s ",
+ formatDuration(snapshot.elapsed())));
+ for (int i = 0; i < viewNames.size(); i++) {
+ int w = colWidths.get(i);
+ Long count = m.viewCounts() != null
+ ? m.viewCounts().get(viewNames.get(i))
+ : null;
+ String value = count != null
+ ? String.format("%" + w + "d", count)
+ : padLeft("N/A", w);
+ row.append(String.format("| %s ", value));
+ }
+ row.append("|");
+ System.out.println(row);
+ }
+
+ System.out.println(border);
+ }
+
+ /**
+ * Prints a summary comparing first and last snapshots.
+ */
+ private void printSummary(MetricsSummary first, MetricsSummary last,
+ List viewNames) {
+ StringBuilder summary = new StringBuilder("Summary: ");
+ List changes = new ArrayList<>();
+
+ // Heap delta
+ if (first.heapUsedBytes() != null && last.heapUsedBytes() != null) {
+ long delta = last.heapUsedBytes() - first.heapUsedBytes();
+ String sign = delta >= 0 ? "+" : "";
+ changes.add("Heap " + sign + first.formatBytes(delta));
+ }
+
+ // Sessions delta
+ if (first.activeSessions() != null && last.activeSessions() != null) {
+ long delta = last.activeSessions() - first.activeSessions();
+ String sign = delta >= 0 ? "+" : "";
+ changes.add("Sessions " + sign + delta);
+ }
+
+ // Vaadin UIs delta
+ if (first.vaadinActiveUis() != null && last.vaadinActiveUis() != null) {
+ long delta = last.vaadinActiveUis() - first.vaadinActiveUis();
+ String sign = delta >= 0 ? "+" : "";
+ changes.add("UIs " + sign + delta);
+ }
+
+ // Average CPU
+ double avgCpu = snapshots.stream()
+ .filter(s -> s.metrics().processCpuPercent() != null)
+ .mapToDouble(s -> s.metrics().processCpuPercent()).average()
+ .orElse(0);
+ if (avgCpu > 0) {
+ changes.add(String.format(Locale.US, "Avg CPU %.1f%%", avgCpu));
+ }
+
+ // Peak CPU
+ double peakCpu = snapshots.stream()
+ .filter(s -> s.metrics().processCpuPercent() != null)
+ .mapToDouble(s -> s.metrics().processCpuPercent()).max()
+ .orElse(0);
+ if (peakCpu > 0) {
+ changes.add(String.format(Locale.US, "Peak CPU %.1f%%", peakCpu));
+ }
+
+ summary.append(String.join(", ", changes));
+ System.out.println(summary.toString());
+ }
+
+ /**
+ * Formats a duration as MM:SS.
+ */
+ private String formatDuration(Duration duration) {
+ long totalSeconds = duration.getSeconds();
+ long minutes = totalSeconds / 60;
+ long seconds = totalSeconds % 60;
+ return String.format("%d:%02d", minutes, seconds);
+ }
+
+ /**
+ * Pads a string to the left to reach the specified width.
+ */
+ private String padLeft(String s, int width) {
+ if (s.length() >= width)
+ return s;
+ return " ".repeat(width - s.length()) + s;
+ }
+
+ /**
+ * A metrics snapshot with timestamp.
+ *
+ * @param elapsed
+ * time elapsed since collection started
+ * @param metrics
+ * the metrics values at this point
+ */
+ public record TimestampedMetrics(Duration elapsed, MetricsSummary metrics) {
+ }
+}
diff --git a/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/NodeRunner.java b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/NodeRunner.java
new file mode 100644
index 000000000..136f5850c
--- /dev/null
+++ b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/NodeRunner.java
@@ -0,0 +1,342 @@
+/**
+ * Copyright (C) 2000-2026 Vaadin Ltd
+ *
+ * This program is available under Vaadin Commercial License and Service Terms.
+ *
+ * See for the full
+ * license.
+ */
+package com.vaadin.testbench.loadtest.util;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.logging.Logger;
+
+import org.apache.maven.plugin.MojoExecutionException;
+
+/**
+ * Manages process execution for the plugin. Now uses Java implementations for
+ * HAR filtering, k6 conversion, and proxy recording. Only k6 execution still
+ * requires an external process.
+ */
+public class NodeRunner {
+
+ private static final Logger log = Logger
+ .getLogger(NodeRunner.class.getName());
+ private final Path workingDirectory;
+ private ProxyRecorder proxyRecorder;
+
+ /**
+ * Creates a new node runner for the given working directory.
+ *
+ * @param workingDirectory
+ * the directory to run node/k6 commands in
+ */
+ public NodeRunner(Path workingDirectory) {
+ this.workingDirectory = workingDirectory;
+ }
+
+ /**
+ * Checks if k6 is available on the system.
+ *
+ * @return true if k6 is installed and accessible
+ */
+ public boolean isK6Available() {
+ try {
+ ProcessBuilder pb = new ProcessBuilder("k6", "version");
+ Process process = pb.start();
+ boolean finished = process.waitFor(10, TimeUnit.SECONDS);
+ if (finished && process.exitValue() == 0) {
+ try (BufferedReader reader = new BufferedReader(
+ new InputStreamReader(process.getInputStream()))) {
+ String version = reader.readLine();
+ log.fine("k6 version: " + version);
+ }
+ return true;
+ }
+ } catch (IOException | InterruptedException e) {
+ log.fine("k6 check failed: " + e.getMessage());
+ }
+ return false;
+ }
+
+ /**
+ * Starts the proxy recorder in the background.
+ *
+ * @param proxyPort
+ * the port for the proxy to listen on
+ * @param harFile
+ * the output HAR file path
+ * @throws MojoExecutionException
+ * if starting the proxy fails
+ */
+ public void startProxyRecorder(int proxyPort, Path harFile)
+ throws MojoExecutionException {
+ log.info("Starting proxy recorder on port " + proxyPort + "...");
+ try {
+ proxyRecorder = new ProxyRecorder();
+ proxyRecorder.start(proxyPort, harFile);
+ log.info("Proxy recorder started");
+ } catch (Exception e) {
+ throw new MojoExecutionException("Failed to start proxy recorder",
+ e);
+ }
+ }
+
+ /**
+ * Stops the proxy recorder gracefully.
+ */
+ public void stopProxyRecorder() {
+ if (proxyRecorder != null && proxyRecorder.isRunning()) {
+ log.info("Stopping proxy recorder...");
+ try {
+ proxyRecorder.stop();
+ log.info("Proxy recorder stopped");
+ } catch (IOException e) {
+ log.warning("Error stopping proxy recorder: " + e.getMessage());
+ }
+ proxyRecorder = null;
+ }
+ }
+
+ /**
+ * Gets the number of requests recorded by the proxy.
+ *
+ * @return the number of recorded HAR entries, or 0 if not recording
+ */
+ public int getRecordedEntryCount() {
+ if (proxyRecorder != null) {
+ return proxyRecorder.getRecordedEntryCount();
+ }
+ return 0;
+ }
+
+ /**
+ * Runs the HAR filter to remove external domain requests.
+ *
+ * @param harFile
+ * the HAR file to filter
+ * @throws MojoExecutionException
+ * if filtering fails
+ */
+ public void filterHar(Path harFile) throws MojoExecutionException {
+ log.info("Filtering external domains from HAR file...");
+ try {
+ HarFilter filter = new HarFilter();
+ filter.filter(harFile);
+ } catch (IOException e) {
+ throw new MojoExecutionException("Failed to filter HAR file", e);
+ }
+ }
+
+ /**
+ * Converts a HAR file to a k6 test with default thresholds.
+ *
+ * @param harFile
+ * the input HAR file
+ * @param outputFile
+ * the output k6 test file
+ * @throws MojoExecutionException
+ * if conversion fails
+ */
+ public void harToK6(Path harFile, Path outputFile)
+ throws MojoExecutionException {
+ harToK6(harFile, outputFile, ThresholdConfig.DEFAULT);
+ }
+
+ /**
+ * Converts a HAR file to a k6 test with configurable thresholds.
+ *
+ * @param harFile
+ * the input HAR file
+ * @param outputFile
+ * the output k6 test file
+ * @param thresholdConfig
+ * threshold configuration for the generated script
+ * @throws MojoExecutionException
+ * if conversion fails
+ */
+ public void harToK6(Path harFile, Path outputFile,
+ ThresholdConfig thresholdConfig) throws MojoExecutionException {
+ log.info("Converting HAR to k6 test...");
+ try {
+ HarToK6Converter converter = new HarToK6Converter();
+ converter.convert(harFile, outputFile, thresholdConfig);
+ } catch (IOException e) {
+ throw new MojoExecutionException("Failed to convert HAR to k6", e);
+ }
+ }
+
+ /**
+ * Refactors a k6 test for Vaadin compatibility with default think time
+ * settings.
+ *
+ * @param inputFile
+ * the input k6 test file
+ * @param outputFile
+ * the output refactored test file
+ * @throws MojoExecutionException
+ * if refactoring fails
+ */
+ public void refactorK6Test(Path inputFile, Path outputFile)
+ throws MojoExecutionException {
+ refactorK6Test(inputFile, outputFile,
+ K6TestRefactorer.ThinkTimeConfig.DEFAULT);
+ }
+
+ /**
+ * Refactors a k6 test for Vaadin compatibility with custom think time
+ * settings.
+ *
+ * @param inputFile
+ * the input k6 test file
+ * @param outputFile
+ * the output refactored test file
+ * @param thinkTimeConfig
+ * think time configuration for realistic user simulation
+ * @throws MojoExecutionException
+ * if refactoring fails
+ */
+ public void refactorK6Test(Path inputFile, Path outputFile,
+ K6TestRefactorer.ThinkTimeConfig thinkTimeConfig)
+ throws MojoExecutionException {
+ log.info("Refactoring k6 test for Vaadin...");
+ try {
+ K6TestRefactorer refactorer = new K6TestRefactorer(thinkTimeConfig);
+ refactorer.refactor(inputFile, outputFile);
+ } catch (IOException e) {
+ throw new MojoExecutionException("Failed to refactor k6 test", e);
+ }
+ }
+
+ /**
+ * Runs a k6 load test.
+ *
+ * @param testFile
+ * the k6 test file to run
+ * @param virtualUsers
+ * number of virtual users
+ * @param duration
+ * test duration (e.g., "30s", "1m")
+ * @param appIp
+ * application IP address
+ * @param appPort
+ * application port
+ * @throws MojoExecutionException
+ * if the test fails
+ */
+ public void runK6Test(Path testFile, int virtualUsers, String duration,
+ String appIp, int appPort) throws MojoExecutionException {
+ runK6Test(testFile, virtualUsers, duration, appIp, appPort, false);
+ }
+
+ /**
+ * Runs a k6 load test.
+ *
+ * @param testFile
+ * the k6 test file to run
+ * @param virtualUsers
+ * number of virtual users
+ * @param duration
+ * test duration (e.g., "30s", "1m")
+ * @param appIp
+ * application IP address
+ * @param appPort
+ * application port
+ * @param useEmbeddedConfig
+ * if true, don't pass --vus and --duration (use script's
+ * embedded config)
+ * @throws MojoExecutionException
+ * if the test fails
+ */
+ public void runK6Test(Path testFile, int virtualUsers, String duration,
+ String appIp, int appPort, boolean useEmbeddedConfig)
+ throws MojoExecutionException {
+ log.info("Running k6 load test: " + testFile.getFileName());
+ if (!useEmbeddedConfig) {
+ log.info(" Virtual Users: " + virtualUsers);
+ log.info(" Duration: " + duration);
+ } else {
+ log.info(" Using embedded scenario configuration");
+ }
+ log.info(" Target: " + appIp + ":" + appPort);
+
+ try {
+ List command = new ArrayList<>();
+ command.add("k6");
+ command.add("run");
+
+ // Only add VUs and duration if not using embedded config
+ if (!useEmbeddedConfig) {
+ command.add("--vus");
+ command.add(String.valueOf(virtualUsers));
+ command.add("--duration");
+ command.add(duration);
+ }
+
+ // Always pass environment variables for target server
+ command.add("-e");
+ command.add("APP_IP=" + appIp);
+ command.add("-e");
+ command.add("APP_PORT=" + String.valueOf(appPort));
+ command.add(testFile.toAbsolutePath().toString());
+
+ ProcessBuilder pb = new ProcessBuilder(command);
+ pb.inheritIO(); // Pass through stdin/stdout/stderr for live output
+
+ Process process = pb.start();
+
+ int exitCode = process.waitFor();
+ if (exitCode != 0) {
+ throw new MojoExecutionException(
+ "k6 test failed with exit code: " + exitCode);
+ }
+ log.info("k6 test completed successfully");
+ } catch (IOException | InterruptedException e) {
+ throw new MojoExecutionException("Failed to run k6 test", e);
+ }
+ }
+
+ // Deprecated methods - kept for backwards compatibility but no longer
+ // needed
+
+ /**
+ * @deprecated Node.js is no longer required. This method always returns
+ * true.
+ */
+ @Deprecated
+ public boolean isNodeAvailable() {
+ return true;
+ }
+
+ /**
+ * @deprecated npm is no longer required. This method always returns true.
+ */
+ @Deprecated
+ public boolean isNpmAvailable() {
+ return true;
+ }
+
+ /**
+ * @deprecated npm dependencies are no longer required. This method is a
+ * no-op.
+ */
+ @Deprecated
+ public void npmInstall() throws MojoExecutionException {
+ // No-op - npm dependencies are no longer needed
+ }
+
+ /**
+ * @deprecated npm dependencies are no longer required. This method always
+ * returns true.
+ */
+ @Deprecated
+ public boolean areDependenciesInstalled() {
+ return true;
+ }
+}
diff --git a/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/ProxyRecorder.java b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/ProxyRecorder.java
new file mode 100644
index 000000000..80bb21355
--- /dev/null
+++ b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/ProxyRecorder.java
@@ -0,0 +1,138 @@
+/**
+ * Copyright (C) 2000-2026 Vaadin Ltd
+ *
+ * This program is available under Vaadin Commercial License and Service Terms.
+ *
+ * See for the full
+ * license.
+ */
+package com.vaadin.testbench.loadtest.util;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.EnumSet;
+import java.util.logging.Logger;
+
+import net.lightbody.bmp.BrowserMobProxy;
+import net.lightbody.bmp.BrowserMobProxyServer;
+import net.lightbody.bmp.core.har.Har;
+import net.lightbody.bmp.proxy.CaptureType;
+
+/**
+ * HTTP Proxy Recorder for k6 tests using BrowserMob Proxy. Records HTTP traffic
+ * and exports it as HAR format.
+ */
+public class ProxyRecorder {
+
+ private static final Logger log = Logger
+ .getLogger(ProxyRecorder.class.getName());
+ private BrowserMobProxy proxy;
+ private Path harOutputPath;
+
+ /**
+ * Creates a new proxy recorder instance.
+ */
+ public ProxyRecorder() {
+ }
+
+ /**
+ * Starts the proxy recorder on the specified port.
+ *
+ * @param port
+ * the port to listen on
+ * @param harOutputPath
+ * the path to write the HAR file
+ */
+ public void start(int port, Path harOutputPath) {
+ this.harOutputPath = harOutputPath;
+
+ proxy = new BrowserMobProxyServer();
+
+ // Enable all capture types for complete HAR recording
+ proxy.setHarCaptureTypes(EnumSet.allOf(CaptureType.class));
+
+ // Disable request/response size limits
+ proxy.setTrustAllServers(true);
+
+ // Strip Accept-Encoding from requests so the server returns
+ // uncompressed
+ // responses. This ensures HAR bodies are readable for Vaadin session
+ // value extraction (syncId, csrfToken, etc.)
+ proxy.addRequestFilter((request, contents, messageInfo) -> {
+ request.headers().remove("Accept-Encoding");
+ return null;
+ });
+
+ // Start proxy on specified port
+ proxy.start(port);
+
+ // Create a new HAR
+ proxy.newHar("k6-recording");
+
+ log.info("Proxy Recorder running on port " + port);
+ log.info("HAR will be stored in: " + harOutputPath);
+ log.info("Waiting for requests...");
+ }
+
+ /**
+ * Stops the proxy recorder and saves the HAR file.
+ *
+ * @throws IOException
+ * if writing the HAR file fails
+ */
+ public void stop() throws IOException {
+ if (proxy != null && proxy.isStarted()) {
+ log.info("Stopping proxy recorder...");
+
+ // Get the recorded HAR
+ Har har = proxy.getHar();
+
+ // Save HAR to file
+ if (har != null && harOutputPath != null) {
+ log.info("Writing HAR file: " + harOutputPath);
+ log.info("Recorded requests: "
+ + har.getLog().getEntries().size());
+ har.writeTo(harOutputPath.toFile());
+ }
+
+ // Stop the proxy
+ proxy.stop();
+ proxy = null;
+
+ log.info("Proxy recorder stopped");
+ }
+ }
+
+ /**
+ * Checks if the proxy is currently running.
+ *
+ * @return true if the proxy is running
+ */
+ public boolean isRunning() {
+ return proxy != null && proxy.isStarted();
+ }
+
+ /**
+ * Gets the current number of recorded entries.
+ *
+ * @return the number of recorded HAR entries
+ */
+ public int getRecordedEntryCount() {
+ if (proxy != null && proxy.getHar() != null) {
+ return proxy.getHar().getLog().getEntries().size();
+ }
+ return 0;
+ }
+
+ /**
+ * Gets the port the proxy is listening on.
+ *
+ * @return the port number, or -1 if not running
+ */
+ public int getPort() {
+ if (proxy != null && proxy.isStarted()) {
+ return proxy.getPort();
+ }
+ return -1;
+ }
+}
diff --git a/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/ResourceExtractor.java b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/ResourceExtractor.java
new file mode 100644
index 000000000..b4961f3bd
--- /dev/null
+++ b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/ResourceExtractor.java
@@ -0,0 +1,110 @@
+/**
+ * Copyright (C) 2000-2026 Vaadin Ltd
+ *
+ * This program is available under Vaadin Commercial License and Service Terms.
+ *
+ * See for the full
+ * license.
+ */
+package com.vaadin.testbench.loadtest.util;
+
+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.List;
+
+/**
+ * Extracts bundled JavaScript utilities from plugin resources to a temporary
+ * directory. Only vaadin-k6-helpers.js is needed now since HAR processing is
+ * done in Java.
+ */
+public class ResourceExtractor {
+
+ private static final String RESOURCE_PREFIX = "k6-utils/";
+ private static final List RESOURCE_FILES = List
+ .of("vaadin-k6-helpers.js");
+
+ private final Path extractionDir;
+
+ /**
+ * Creates a new resource extractor that writes to the given directory.
+ *
+ * @param extractionDir
+ * the directory to extract bundled k6 utilities into
+ */
+ public ResourceExtractor(Path extractionDir) {
+ this.extractionDir = extractionDir;
+ }
+
+ /**
+ * Extracts all bundled k6 utilities to the extraction directory.
+ *
+ * @return the directory containing extracted utilities
+ * @throws IOException
+ * if extraction fails
+ */
+ public Path extractUtilities() throws IOException {
+ Files.createDirectories(extractionDir);
+
+ for (String resourceFile : RESOURCE_FILES) {
+ extractResource(resourceFile);
+ }
+
+ return extractionDir;
+ }
+
+ /**
+ * Extracts a single resource file to the extraction directory.
+ *
+ * @param fileName
+ * the name of the resource file to extract
+ * @throws IOException
+ * if the resource cannot be found or extraction fails
+ */
+ private void extractResource(String fileName) throws IOException {
+ String resourcePath = RESOURCE_PREFIX + fileName;
+ try (InputStream is = getClass().getClassLoader()
+ .getResourceAsStream(resourcePath)) {
+ if (is == null) {
+ throw new IOException("Resource not found: " + resourcePath);
+ }
+ Path targetPath = extractionDir.resolve(fileName);
+ Files.copy(is, targetPath, StandardCopyOption.REPLACE_EXISTING);
+ }
+ }
+
+ /**
+ * @return the path to the vaadin-k6-helpers.js file
+ */
+ public Path getVaadinHelpersScript() {
+ return extractionDir.resolve("vaadin-k6-helpers.js");
+ }
+
+ /**
+ * @return the extraction directory path
+ */
+ public Path getExtractionDir() {
+ return extractionDir;
+ }
+
+ /**
+ * Cleans up the extraction directory.
+ *
+ * @throws IOException
+ * if cleanup fails
+ */
+ public void cleanup() throws IOException {
+ if (Files.exists(extractionDir)) {
+ Files.walk(extractionDir).sorted((a, b) -> -a.compareTo(b))
+ .forEach(path -> {
+ try {
+ Files.deleteIfExists(path);
+ } catch (IOException e) {
+ // Ignore cleanup errors
+ }
+ });
+ }
+ }
+}
diff --git a/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/ServerProcess.java b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/ServerProcess.java
new file mode 100644
index 000000000..36bf4d705
--- /dev/null
+++ b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/ServerProcess.java
@@ -0,0 +1,161 @@
+/**
+ * Copyright (C) 2000-2026 Vaadin Ltd
+ *
+ * This program is available under Vaadin Commercial License and Service Terms.
+ *
+ * See for the full
+ * license.
+ */
+package com.vaadin.testbench.loadtest.util;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.util.List;
+import java.util.logging.Logger;
+
+/**
+ * Manages a server process lifecycle: start, health-check polling, and stop.
+ * Uses {@link java.lang.Process} for cross-platform process management (no
+ * shell commands like {@code pkill} required).
+ */
+public class ServerProcess {
+
+ private static final Logger log = Logger
+ .getLogger(ServerProcess.class.getName());
+ private Process process;
+ private Thread outputThread;
+
+ /**
+ * Starts the server process asynchronously.
+ *
+ * @param command
+ * the command to execute (e.g., ["java", "-jar", "app.jar",
+ * ...])
+ * @throws Exception
+ * if the process cannot be started
+ */
+ public void start(List command) throws Exception {
+ log.info("Starting server: " + String.join(" ", command));
+
+ ProcessBuilder pb = new ProcessBuilder(command);
+ pb.redirectErrorStream(true);
+ process = pb.start();
+
+ // Consume stdout/stderr on a daemon thread to prevent process blocking
+ outputThread = new Thread(() -> {
+ try (BufferedReader reader = new BufferedReader(
+ new InputStreamReader(process.getInputStream()))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ log.info("[server] " + line);
+ }
+ } catch (Exception e) {
+ // Process ended or stream closed
+ }
+ }, "k6-server-output");
+ outputThread.setDaemon(true);
+ outputThread.start();
+ }
+
+ /**
+ * Polls health URLs until all return HTTP status < 500, or timeout is
+ * reached.
+ *
+ * @param healthUrls
+ * URLs to poll (each must become healthy in sequence)
+ * @param timeout
+ * maximum time to wait
+ * @param pollInterval
+ * time between poll attempts
+ * @throws Exception
+ * if timeout is reached or the process dies during startup
+ */
+ public void waitForReady(List healthUrls, Duration timeout,
+ Duration pollInterval) throws Exception {
+ HttpClient client = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(3)).build();
+
+ for (String url : healthUrls) {
+ log.info("Waiting for " + url + " ...");
+ long deadline = System.currentTimeMillis() + timeout.toMillis();
+
+ while (System.currentTimeMillis() < deadline) {
+ if (!isAlive()) {
+ throw new Exception(
+ "Server process died during startup (exit code: "
+ + process.exitValue() + ")");
+ }
+ try {
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(url)).timeout(Duration.ofSeconds(3))
+ .GET().build();
+ HttpResponse response = client.send(request,
+ HttpResponse.BodyHandlers.ofString());
+ if (response.statusCode() < 500) {
+ log.info(url + " is ready (status "
+ + response.statusCode() + ")");
+ break;
+ }
+ } catch (Exception e) {
+ // Connection refused or timeout — not ready yet
+ log.fine("Not ready yet: " + e.getMessage());
+ }
+
+ if (System.currentTimeMillis() >= deadline) {
+ throw new Exception("Timeout waiting for " + url + " after "
+ + timeout.getSeconds() + "s");
+ }
+ Thread.sleep(pollInterval.toMillis());
+ }
+ }
+ }
+
+ /**
+ * Stops the server process. Sends a graceful shutdown signal first, then
+ * force-kills if the process does not exit within the grace period.
+ *
+ * @param gracePeriod
+ * time to wait for graceful shutdown before force-killing
+ */
+ public void stop(Duration gracePeriod) {
+ if (process == null || !process.isAlive()) {
+ log.info("No server process to stop");
+ return;
+ }
+
+ log.info("Stopping server process...");
+ process.destroy();
+
+ try {
+ boolean exited = process.waitFor(gracePeriod.toMillis(),
+ java.util.concurrent.TimeUnit.MILLISECONDS);
+ if (!exited) {
+ log.warning("Server did not stop within "
+ + gracePeriod.getSeconds() + "s, force-killing");
+ process.destroyForcibly();
+ process.waitFor(5, java.util.concurrent.TimeUnit.MILLISECONDS);
+ }
+ } catch (InterruptedException e) {
+ process.destroyForcibly();
+ Thread.currentThread().interrupt();
+ }
+
+ if (!process.isAlive()) {
+ log.info("Server stopped (exit code: " + process.exitValue() + ")");
+ }
+ }
+
+ /**
+ * Returns whether the server process is still running.
+ *
+ * @return {@code true} if the process is running
+ */
+ public boolean isAlive() {
+ return process != null && process.isAlive();
+ }
+}
diff --git a/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/SourceHasher.java b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/SourceHasher.java
new file mode 100644
index 000000000..f1042a050
--- /dev/null
+++ b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/SourceHasher.java
@@ -0,0 +1,144 @@
+/**
+ * Copyright (C) 2000-2026 Vaadin Ltd
+ *
+ * This program is available under Vaadin Commercial License and Service Terms.
+ *
+ * See for the full
+ * license.
+ */
+package com.vaadin.testbench.loadtest.util;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.logging.Logger;
+import java.util.stream.Stream;
+
+/**
+ * Calculates hashes of source files to detect changes and enable caching. Used
+ * to skip re-recording TestBench tests when sources haven't changed.
+ */
+public class SourceHasher {
+
+ private static final Logger log = Logger
+ .getLogger(SourceHasher.class.getName());
+
+ /**
+ * Creates a new source hasher instance.
+ */
+ public SourceHasher() {
+ }
+
+ /**
+ * Calculates a combined hash of the test class source file and pom.xml.
+ *
+ * @param testWorkDir
+ * the project directory containing the test
+ * @param testClass
+ * the test class name (e.g., "HelloWorldIT")
+ * @return hex string of the combined hash, or null if files not found
+ */
+ public String calculateSourceHash(Path testWorkDir, String testClass) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+
+ // Find and hash the test class source file
+ Path testFile = findTestFile(testWorkDir, testClass);
+ if (testFile != null && Files.exists(testFile)) {
+ byte[] content = Files.readAllBytes(testFile);
+ digest.update(content);
+ log.fine("Hashed test file: " + testFile);
+ } else {
+ log.fine("Test file not found for: " + testClass);
+ }
+
+ // Hash the pom.xml
+ Path pomFile = testWorkDir.resolve("pom.xml");
+ if (Files.exists(pomFile)) {
+ byte[] content = Files.readAllBytes(pomFile);
+ digest.update(content);
+ log.fine("Hashed pom.xml: " + pomFile);
+ }
+
+ byte[] hash = digest.digest();
+ return HexFormat.of().formatHex(hash);
+
+ } catch (NoSuchAlgorithmException | IOException e) {
+ log.warning("Failed to calculate source hash: " + e.getMessage());
+ return null;
+ }
+ }
+
+ /**
+ * Finds a test class source file by searching common locations.
+ *
+ * @param projectDir
+ * the project directory to search in
+ * @param className
+ * the test class name to find
+ * @return the path to the source file, or {@code null} if not found
+ */
+ private Path findTestFile(Path projectDir, String className) {
+ // Common test source locations
+ List searchPaths = List.of("src/test/java", "src/it/java");
+
+ for (String searchPath : searchPaths) {
+ Path searchDir = projectDir.resolve(searchPath);
+ if (Files.exists(searchDir)) {
+ try (Stream files = Files.walk(searchDir)) {
+ Path found = files
+ .filter(p -> p.getFileName().toString()
+ .equals(className + ".java"))
+ .findFirst().orElse(null);
+ if (found != null) {
+ return found;
+ }
+ } catch (IOException e) {
+ log.fine("Error searching " + searchDir + ": "
+ + e.getMessage());
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Reads a stored hash from a file.
+ *
+ * @param hashFile
+ * path to the hash file
+ * @return the stored hash, or null if file doesn't exist
+ */
+ public String readStoredHash(Path hashFile) {
+ if (Files.exists(hashFile)) {
+ try {
+ return Files.readString(hashFile).trim();
+ } catch (IOException e) {
+ log.fine("Failed to read hash file: " + e.getMessage());
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Stores a hash to a file.
+ *
+ * @param hashFile
+ * path to the hash file
+ * @param hash
+ * the hash to store
+ */
+ public void storeHash(Path hashFile, String hash) {
+ try {
+ Files.createDirectories(hashFile.getParent());
+ Files.writeString(hashFile, hash);
+ log.fine("Stored hash to: " + hashFile);
+ } catch (IOException e) {
+ log.warning("Failed to store hash: " + e.getMessage());
+ }
+ }
+}
diff --git a/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/ThresholdConfig.java b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/ThresholdConfig.java
new file mode 100644
index 000000000..bd4dbac47
--- /dev/null
+++ b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/java/com/vaadin/testbench/loadtest/util/ThresholdConfig.java
@@ -0,0 +1,69 @@
+/**
+ * Copyright (C) 2000-2026 Vaadin Ltd
+ *
+ * This program is available under Vaadin Commercial License and Service Terms.
+ *
+ * See for the full
+ * license.
+ */
+package com.vaadin.testbench.loadtest.util;
+
+/**
+ * Configuration for k6 test thresholds. Controls when the load test is
+ * considered failed based on response times and check pass rates.
+ *
+ * @param httpReqDurationP95
+ * 95th percentile response time threshold in ms (0 to disable)
+ * @param httpReqDurationP99
+ * 99th percentile response time threshold in ms (0 to disable)
+ * @param checksAbortOnFail
+ * if true, abort the test immediately when a check fails
+ */
+public record ThresholdConfig(int httpReqDurationP95, int httpReqDurationP99,
+ boolean checksAbortOnFail) {
+
+ /**
+ * Default thresholds: p95 < 2000ms, p99 < 5000ms, abort on check
+ * failure.
+ */
+ public static final ThresholdConfig DEFAULT = new ThresholdConfig(2000,
+ 5000, true);
+
+ /**
+ * Generates the k6 thresholds block for use inside
+ * {@code export const options}. Example output:
+ *
+ *
+ * thresholds: {
+ * checks: [{ threshold: 'rate==1', abortOnFail: true, delayAbortEval: '5s' }],
+ * http_req_duration: ['p(95)<2000', 'p(99)<5000'],
+ * },
+ *
+ *
+ * @return the k6 thresholds block as a string
+ */
+ public String toK6ThresholdsBlock() {
+ StringBuilder sb = new StringBuilder();
+ sb.append(" thresholds: {\n");
+ if (checksAbortOnFail) {
+ sb.append(
+ " checks: [{ threshold: 'rate==1', abortOnFail: true, delayAbortEval: '5s' }],\n");
+ } else {
+ sb.append(" checks: ['rate==1'],\n");
+ }
+ sb.append(" http_req_duration: [");
+ boolean first = true;
+ if (httpReqDurationP95 > 0) {
+ sb.append("'p(95)<").append(httpReqDurationP95).append("'");
+ first = false;
+ }
+ if (httpReqDurationP99 > 0) {
+ if (!first)
+ sb.append(", ");
+ sb.append("'p(99)<").append(httpReqDurationP99).append("'");
+ }
+ sb.append("],\n");
+ sb.append(" },\n");
+ return sb.toString();
+ }
+}
diff --git a/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/resources/com/vaadin/testbench/loadtest/testbench.properties b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/resources/com/vaadin/testbench/loadtest/testbench.properties
new file mode 100644
index 000000000..4f009c1a5
--- /dev/null
+++ b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/resources/com/vaadin/testbench/loadtest/testbench.properties
@@ -0,0 +1 @@
+testbench.version=${version}
diff --git a/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/resources/k6-utils/vaadin-k6-helpers.js b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/resources/k6-utils/vaadin-k6-helpers.js
new file mode 100644
index 000000000..119f4c25d
--- /dev/null
+++ b/vaadin-testbench-loadtest/testbench-converter-plugin/src/main/resources/k6-utils/vaadin-k6-helpers.js
@@ -0,0 +1,200 @@
+/**
+ * Vaadin k6 Helper Functions
+ *
+ * Reusable utility functions for load testing Vaadin applications with k6.
+ * Import these functions in your k6 test scripts to interact with Vaadin's
+ * UIDL (UI Description Language) protocol.
+ *
+ * Usage:
+ * import { initPageLoad, vaadinRequest, vaadinUnloadRequest } from './vaadin-k6-helpers.js';
+ *
+ * const BASE_URL = "http://localhost:8080";
+ * const vaadinInfo = initPageLoad(BASE_URL);
+ * vaadinRequest(BASE_URL, vaadinInfo, `[{...}]`, 0, "");
+ */
+
+import http from "k6/http";
+import { check, fail } from "k6";
+
+/**
+ * Creates HTTP request parameters with standard headers for Vaadin UIDL requests.
+ * @param {string} baseUrl - The base URL of the application
+ * @param {string} route - The current route path for the Referer header
+ * @returns {object} HTTP request parameters with headers and cookies
+ */
+export function createBaseParams(baseUrl, route) {
+ return {
+ headers: {
+ "Proxy-Connection": `keep-alive`,
+ "Content-type": `application/json; charset=UTF-8`,
+ Accept: `*/*`,
+ Origin: baseUrl,
+ Referer: `${baseUrl}/${route}`,
+ "Accept-Encoding": `gzip, deflate`,
+ "Accept-Language": `en-GB,en-US;q=0.9,en;q=0.8`,
+ },
+ cookies: {},
+ };
+}
+
+/**
+ * Extracts the JSESSIONID from a response.
+ * @param {Response} response - response
+ * @returns {string|null} The JSESSIONID value or null if not found
+ */
+export function extractJSessionId(response) {
+ const cookieString = response.headers["Set-Cookie"];
+
+ if (!cookieString) return null;
+ const match = cookieString.match(/JSESSIONID=([^;]+)/);
+ return match ? match[1] : null;
+}
+
+/**
+ * Extracts the Vaadin CSRF security token from the HTML response.
+ * @param {string} html - The HTML response body
+ * @returns {string|null} The CSRF token or null if not found
+ */
+export function getVaadinSecurityKey(html) {
+ const match = html.match(/["']Vaadin-Security-Key["']\s*:\s*["']([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})["']/);
+ return match ? match[1] : null;
+}
+
+/**
+ * Extracts the Vaadin Push ID from the HTML response.
+ * @param {string} html - The HTML response body
+ * @returns {string|null} The Push ID or null if not found
+ */
+export function getVaadinPushId(html) {
+ const match = html.match(/["']Vaadin-Push-ID["']\s*:\s*["']([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})["']/);
+ return match ? match[1] : null;
+}
+
+/**
+ * Extracts the Vaadin UI ID from the HTML response.
+ * @param {string} html - The HTML response body
+ * @returns {number|null} The UI ID or null if not found
+ */
+export function getVaadinUiId(html) {
+ const match = html.match(/["']v-uiId["']\s*:\s*(\d+)/);
+ return match ? Number(match[1]) : null;
+}
+
+/**
+ * Initializes the Vaadin application by loading the page and extracting session info.
+ * Performs two requests:
+ * 1. GET / - Load the initial HTML page
+ * 2. GET /?v-r=init - Initialize Vaadin with browser details (screen size, timezone, etc.)
+ * @param {string} baseUrl - The base URL of the application
+ * @returns {object} Object containing csrfToken and uiID for subsequent requests
+ */
+export function initPageLoad(baseUrl) {
+
+ // Request 1: Load initial HTML page
+ let params = {
+ headers: {
+ "Proxy-Connection": `keep-alive`,
+ "Upgrade-Insecure-Requests": `1`,
+ Accept: `text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7`,
+ "Accept-Encoding": `gzip, deflate`,
+ "Accept-Language": `en-GB,en-US;q=0.9,en;q=0.8`,
+ },
+ cookies: {},
+ };
+
+ let url = http.url`${baseUrl}/`;
+ let resp = http.request("GET", url, null, params);
+
+ if (!check(resp, { "page load status equals 200": (r) => r.status === 200 })) {
+ fail(`Page load failed with status: ${resp.status}`);
+ }
+
+ // Request 2: Initialize Vaadin session with browser details
+ params = {
+ headers: {
+ "Proxy-Connection": `keep-alive`,
+ Accept: `*/*`,
+ Referer: `${baseUrl}/`,
+ "Accept-Encoding": `gzip, deflate`,
+ "Accept-Language": `en-GB,en-US;q=0.9,en;q=0.8`,
+ },
+ cookies: {},
+ };
+
+ url = http.url`${baseUrl}/?v-r=init&location=&query=&v-browserDetails=%7B%22v-sh%22%3A%222160%22%2C%22v-sw%22%3A%223840%22%2C%22v-wh%22%3A%221934%22%2C%22v-ww%22%3A%221200%22%2C%22v-bh%22%3A%221934%22%2C%22v-bw%22%3A%221200%22%2C%22v-curdate%22%3A%221768550330146%22%2C%22v-tzo%22%3A%22-60%22%2C%22v-dstd%22%3A%2260%22%2C%22v-rtzo%22%3A%22-60%22%2C%22v-dston%22%3A%22false%22%2C%22v-tzid%22%3A%22Europe%2FBerlin%22%2C%22v-wn%22%3A%22v-0.8318380938909788%22%2C%22v-td%22%3A%22false%22%2C%22v-pr%22%3A%221%22%2C%22v-np%22%3A%22MacIntel%22%2C%22v-cs%22%3A%22light%22%2C%22v-tn%22%3A%22lumo%22%7D`;
+ resp = http.request("GET", url, null, params);
+
+ if (!check(resp, { "vaadin init status equals 200": (r) => r.status === 200 })) {
+ fail(`Vaadin init failed with status: ${resp.status}`);
+ }
+
+ // Extract session info for subsequent requests
+ const csrfToken = getVaadinSecurityKey(resp.body);
+ const uiID = getVaadinUiId(resp.body);
+
+ if (!csrfToken || uiID === null) {
+ fail(`Failed to extract Vaadin session values (csrfToken=${csrfToken}, uiID=${uiID})`);
+ }
+
+ return { csrfToken, uiID };
+}
+
+/**
+ * Sends a Vaadin UIDL (UI Description Language) request with RPC payload.
+ * @param {string} baseUrl - The base URL of the application
+ * @param {object} vaadinInfo - Object containing csrfToken and uiID
+ * @param {string} rpcPayload - JSON array string of RPC calls to execute
+ * @param {number} idCounter - Sync/client ID counter for request ordering
+ * @param {string} route - Current route path for the Referer header
+ * @returns {object} HTTP response object
+ */
+export function vaadinRequest(baseUrl, vaadinInfo, rpcPayload, idCounter, route) {
+ let url = http.url`${baseUrl}/?v-r=uidl&v-uiId=${vaadinInfo.uiID}`;
+
+ const resp = http.request(
+ "POST",
+ url,
+ `{
+ "csrfToken":"${vaadinInfo.csrfToken}",
+ "rpc":${rpcPayload},
+ "syncId":${idCounter},
+ "clientId":${idCounter}
+ }`,
+ createBaseParams(baseUrl, route),
+ );
+
+ if (!check(resp, {
+ 'UIDL request succeeded': (r) => r.status === 200,
+ 'no server error': (r) => !r.body.includes('"appError"'),
+ 'session is valid': (r) => !r.body.includes('Your session needs to be refreshed'),
+ 'security key valid': (r) => !r.body.includes('Invalid security key'),
+ 'valid UIDL response': (r) => r.body.includes('"syncId"'),
+ })) {
+ fail(`UIDL request failed (status ${resp.status}): ${resp.body.substring(0, 200)}`);
+ }
+
+ return resp;
+}
+
+/**
+ * Sends a Vaadin session unload request when user leaves the page.
+ * This properly terminates the server-side session.
+ * @param {string} baseUrl - The base URL of the application
+ * @param {object} vaadinInfo - Object containing csrfToken and uiID
+ * @param {string} route - Current route path for the Referer header
+ * @returns {object} HTTP response object
+ */
+export function vaadinUnloadRequest(baseUrl, vaadinInfo, route) {
+ let url = http.url`${baseUrl}/?v-r=uidl&v-uiId=${vaadinInfo.uiID}`;
+
+ return http.request(
+ "POST",
+ url,
+ `{
+ "csrfToken":"${vaadinInfo.csrfToken}",
+ "rpc":[],
+ "UNLOAD":true
+ }`,
+ createBaseParams(baseUrl, route),
+ );
+}
diff --git a/vaadin-testbench-loadtest/testbench-converter-plugin/src/test/java/com/vaadin/testbench/loadtest/AbstractK6MojoLicenseTest.java b/vaadin-testbench-loadtest/testbench-converter-plugin/src/test/java/com/vaadin/testbench/loadtest/AbstractK6MojoLicenseTest.java
new file mode 100644
index 000000000..04f284cd1
--- /dev/null
+++ b/vaadin-testbench-loadtest/testbench-converter-plugin/src/test/java/com/vaadin/testbench/loadtest/AbstractK6MojoLicenseTest.java
@@ -0,0 +1,76 @@
+/**
+ * Copyright (C) 2000-2026 Vaadin Ltd
+ *
+ * This program is available under Vaadin Commercial License and Service Terms.
+ *
+ * See for the full
+ * license.
+ */
+package com.vaadin.testbench.loadtest;
+
+import org.apache.maven.plugin.MojoExecutionException;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import com.vaadin.pro.licensechecker.Capabilities;
+import com.vaadin.pro.licensechecker.LicenseChecker;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class AbstractK6MojoLicenseTest {
+
+ /**
+ * Concrete subclass for testing the abstract base class.
+ */
+ static class TestMojo extends AbstractK6Mojo {
+ @Override
+ public void execute() {
+ // no-op
+ }
+ }
+
+ @Test
+ void checkLicense_callsLicenseChecker() throws MojoExecutionException {
+ TestMojo mojo = new TestMojo();
+
+ try (MockedStatic licenseChecker = Mockito
+ .mockStatic(LicenseChecker.class)) {
+ mojo.checkLicense();
+
+ licenseChecker.verify(() -> {
+ LicenseChecker.checkLicenseFromStaticBlock(
+ Mockito.eq("vaadin-testbench"), Mockito.anyString(),
+ Mockito.isNull(), Mockito.any(Capabilities.class));
+ });
+ }
+ }
+
+ @Test
+ void checkLicense_propagatesLicenseCheckerException() {
+ TestMojo mojo = new TestMojo();
+
+ try (MockedStatic licenseChecker = Mockito
+ .mockStatic(LicenseChecker.class)) {
+ licenseChecker
+ .when(() -> LicenseChecker.checkLicenseFromStaticBlock(
+ Mockito.anyString(), Mockito.anyString(),
+ Mockito.isNull(), Mockito.any()))
+ .thenThrow(new RuntimeException("License check failed"));
+
+ assertThrows(RuntimeException.class, mojo::checkLicense);
+ }
+ }
+
+ @Test
+ void checkLicense_succeedsWhenLicenseValid() {
+ TestMojo mojo = new TestMojo();
+
+ try (MockedStatic licenseChecker = Mockito
+ .mockStatic(LicenseChecker.class)) {
+ // Static mock does nothing by default — simulates valid license
+ assertDoesNotThrow(() -> mojo.checkLicense());
+ }
+ }
+}
diff --git a/vaadin-testbench-loadtest/testbench-loadtest-support/README.md b/vaadin-testbench-loadtest/testbench-loadtest-support/README.md
new file mode 100644
index 000000000..dc3a56098
--- /dev/null
+++ b/vaadin-testbench-loadtest/testbench-loadtest-support/README.md
@@ -0,0 +1,134 @@
+# TestBench k6 Recording Support
+
+> **TEMPORARY SOLUTION:** This library is a development-phase workaround. In the future,
+> this functionality should be integrated directly into Vaadin TestBench, eliminating
+> the need for a separate dependency.
+
+A JUnit 5 extension that enables transparent k6 recording proxy support for Vaadin TestBench tests.
+
+## Overview
+
+This library allows you to use regular Vaadin TestBench tests for k6 load test recordings without requiring special base classes or any code changes. When recording mode is enabled (via system properties), the extension automatically configures the browser to route traffic through a recording proxy.
+
+## Features
+
+- **Non-invasive**: Uses standard JUnit 5 extension auto-detection - no code changes required
+- **Backward compatible**: Tests run normally when not in recording mode
+- **Configuration via system properties**: No annotations or special classes needed
+
+## Installation
+
+Add the dependency to your test scope:
+
+```xml
+
+
+ com.vaadin
+ testbench-loadtest-support
+ 1.0-SNAPSHOT
+ test
+
+```
+
+## Usage
+
+### Writing Tests
+
+Write your TestBench tests as normal. The extension works with any test class that extends `BrowserTestBase` (directly or indirectly) and has a `getViewName()` method:
+
+```java
+public class MyScenario extends BrowserTestBase {
+
+ @BrowserTest
+ public void userWorkflow() {
+ // Your TestBench test code - no special changes needed
+ $(TextFieldElement.class).first().setValue("test");
+ $(ButtonElement.class).first().click();
+ }
+
+ // Required: Tell the extension which view to open
+ public String getViewName() {
+ return "my-view";
+ }
+}
+```
+
+### Running Tests (Normal Mode)
+
+```bash
+mvn test -Dtest=MyScenario
+```
+
+Tests run without proxy configuration - standard TestBench behavior.
+
+### Running Tests (Recording Mode)
+
+Enable JUnit auto-detection and set the proxy host:
+
+```bash
+mvn test -Dtest=MyScenario \
+ -Djunit.jupiter.extensions.autodetection.enabled=true \
+ -Dk6.proxy.host=localhost:6000 \
+ -Dserver.port=8081
+```
+
+The extension will automatically:
+1. Close the default WebDriver created by BrowserTestBase
+2. Create a new ChromeDriver configured with proxy settings
+3. Apply necessary Chrome flags for MITM proxy support
+4. Navigate to the test view
+
+## Configuration
+
+All configuration is done via system properties:
+
+| Property | Default | Description |
+|----------|---------|-------------|
+| `junit.jupiter.extensions.autodetection.enabled` | `false` | Must be `true` to enable the extension |
+| `k6.proxy.host` | (none) | Proxy host:port. Recording is enabled only when set |
+| `server.host` | `127.0.0.1` | Host where the application is running |
+| `server.port` | `8080` | Port where the application is running |
+
+## How It Works
+
+The extension uses JUnit 5's ServiceLoader auto-detection mechanism. When enabled and a proxy is configured, the extension:
+
+1. **Intercepts test startup**: Hooks into JUnit's `BeforeEach` lifecycle
+2. **Replaces the driver**: Closes TestBench's auto-started driver
+3. **Configures proxy**: Creates a new ChromeDriver with HTTP/HTTPS proxy settings
+4. **Applies Chrome flags**:
+ - `--ignore-certificate-errors` for MITM proxy
+ - `--proxy-bypass-list=<-loopback>` to force localhost through proxy
+5. **Navigates to view**: Opens the view URL returned by `getViewName()`
+
+## Integration with testbench-converter-plugin
+
+This library is designed to work seamlessly with the `testbench-converter-plugin` Maven plugin. The plugin automatically enables the extension when recording:
+
+```bash
+mvn com.vaadin:testbench-converter-plugin:record -Dk6.testClass=MyScenario
+```
+
+## Requirements
+
+- Java 21+
+- Vaadin TestBench (with JUnit 6 support)
+- Chrome browser with ChromeDriver
+- A running k6 recording proxy (e.g., via `testbench-converter-plugin`)
+
+## Future Integration into TestBench
+
+This functionality is planned to be integrated directly into Vaadin TestBench. When that happens:
+
+1. This library will no longer be needed
+2. Tests will continue to work without any changes
+3. The `testbench-loadtest-support` dependency can simply be removed
+
+The integration would likely be done by:
+- Adding proxy configuration support directly to TestBench's driver creation
+- Exposing the same system properties for backward compatibility
+- Optionally providing a more integrated configuration mechanism
diff --git a/vaadin-testbench-loadtest/testbench-loadtest-support/pom.xml b/vaadin-testbench-loadtest/testbench-loadtest-support/pom.xml
new file mode 100644
index 000000000..528a61c9c
--- /dev/null
+++ b/vaadin-testbench-loadtest/testbench-loadtest-support/pom.xml
@@ -0,0 +1,52 @@
+
+
+ 4.0.0
+
+
+ com.vaadin
+ vaadin-testbench-loadtest
+ 10.2-SNAPSHOT
+
+
+ testbench-loadtest-support
+ TestBench k6 Recording Support
+
+ JUnit 5 extension for transparent k6 recording proxy support in Vaadin TestBench tests.
+
+ This library enables k6 load test recording without requiring special base classes.
+ It can be auto-registered via JUnit 5's ServiceLoader mechanism or explicitly
+ via the @K6Recording annotation.
+
+
+
+ 5.10.2
+
+
+
+
+ com.vaadin
+ vaadin-testbench-shared
+ ${project.version}
+ provided
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ ${junit.version}
+ provided
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+
+
diff --git a/vaadin-testbench-loadtest/testbench-loadtest-support/src/main/java/com/vaadin/testbench/loadtest/Destructive.java b/vaadin-testbench-loadtest/testbench-loadtest-support/src/main/java/com/vaadin/testbench/loadtest/Destructive.java
new file mode 100644
index 000000000..482f69cb0
--- /dev/null
+++ b/vaadin-testbench-loadtest/testbench-loadtest-support/src/main/java/com/vaadin/testbench/loadtest/Destructive.java
@@ -0,0 +1,40 @@
+/**
+ * Copyright (C) 2000-2026 Vaadin Ltd
+ *
+ * This program is available under Vaadin Commercial License and Service Terms.
+ *
+ * See for the full
+ * license.
+ */
+package com.vaadin.testbench.loadtest;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Marks a test method as destructive (e.g., deletes data) so it is skipped
+ * during k6 load test recording.
+ *
+ * Destructive tests modify shared state in ways that cannot be safely repeated
+ * by multiple virtual users (e.g., deleting a specific entity). When the k6
+ * recording proxy is active, methods annotated with {@code @Destructive} are
+ * automatically skipped.
+ *
+ * Example usage:
+ *
+ *
+ * {@literal @}Test
+ * {@literal @}Destructive
+ * public void deletePatient() {
+ * // This test will be skipped during k6 recording
+ * }
+ *
+ *
+ * @see K6RecordingExtension
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface Destructive {
+}
diff --git a/vaadin-testbench-loadtest/testbench-loadtest-support/src/main/java/com/vaadin/testbench/loadtest/K6RecordingExtension.java b/vaadin-testbench-loadtest/testbench-loadtest-support/src/main/java/com/vaadin/testbench/loadtest/K6RecordingExtension.java
new file mode 100644
index 000000000..7c8c5cb74
--- /dev/null
+++ b/vaadin-testbench-loadtest/testbench-loadtest-support/src/main/java/com/vaadin/testbench/loadtest/K6RecordingExtension.java
@@ -0,0 +1,94 @@
+/**
+ * Copyright (C) 2000-2026 Vaadin Ltd
+ *
+ * This program is available under Vaadin Commercial License and Service Terms.
+ *
+ * See for the full
+ * license.
+ */
+package com.vaadin.testbench.loadtest;
+
+import org.junit.jupiter.api.extension.AfterEachCallback;
+import org.junit.jupiter.api.extension.ConditionEvaluationResult;
+import org.junit.jupiter.api.extension.ExecutionCondition;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.openqa.selenium.WebDriver;
+
+import com.vaadin.testbench.HasDriver;
+
+/**
+ * JUnit 5 extension for k6 recording support.
+ *
+ * This extension provides two features:
+ *
+ * - Skips test methods annotated with {@link Destructive} when the k6
+ * recording proxy is active (detected via the {@code k6.proxy.host} system
+ * property).
+ * - Ensures WebDriver cleanup after each test to prevent orphaned Chrome
+ * processes.
+ *
+ *
+ * This extension uses JUnit 5's ServiceLoader auto-detection mechanism. Enable
+ * it by setting:
+ *
+ *
+ * -Djunit.jupiter.extensions.autodetection.enabled=true
+ *
+ */
+public class K6RecordingExtension
+ implements ExecutionCondition, AfterEachCallback {
+
+ private static final ConditionEvaluationResult ENABLED = ConditionEvaluationResult
+ .enabled("Not in k6 recording mode or test is not @Destructive");
+
+ private static final ConditionEvaluationResult DISABLED = ConditionEvaluationResult
+ .disabled("@Destructive test skipped during k6 recording");
+
+ /**
+ * Public no-arg constructor required for ServiceLoader.
+ */
+ public K6RecordingExtension() {
+ }
+
+ @Override
+ public ConditionEvaluationResult evaluateExecutionCondition(
+ ExtensionContext context) {
+ // Only skip if we're in k6 recording mode
+ if (!isK6RecordingActive()) {
+ return ENABLED;
+ }
+
+ // Check if the test method is annotated with @Destructive
+ return context.getTestMethod()
+ .filter(method -> method.isAnnotationPresent(Destructive.class))
+ .map(method -> DISABLED).orElse(ENABLED);
+ }
+
+ @Override
+ public void afterEach(ExtensionContext context) throws Exception {
+ Object testInstance = context.getRequiredTestInstance();
+ if (!(testInstance instanceof HasDriver)) {
+ return;
+ }
+
+ HasDriver testBase = (HasDriver) testInstance;
+ WebDriver driver = testBase.getDriver();
+ if (driver != null) {
+ try {
+ driver.quit();
+ } catch (Exception e) {
+ // Log but don't fail - driver may already be closed
+ System.err.println(
+ "Warning: Failed to quit WebDriver: " + e.getMessage());
+ }
+ }
+ }
+
+ /**
+ * Returns true if k6 recording proxy is active.
+ */
+ private static boolean isK6RecordingActive() {
+ String proxyHost = System.getProperty("k6.proxy.host");
+ return proxyHost != null && !proxyHost.isEmpty();
+ }
+}
diff --git a/vaadin-testbench-loadtest/testbench-loadtest-support/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension b/vaadin-testbench-loadtest/testbench-loadtest-support/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension
new file mode 100644
index 000000000..8d044c85d
--- /dev/null
+++ b/vaadin-testbench-loadtest/testbench-loadtest-support/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension
@@ -0,0 +1 @@
+com.vaadin.testbench.loadtest.K6RecordingExtension