diff --git a/pom.xml b/pom.xml
index 6abd5d8..3e973ae 100644
--- a/pom.xml
+++ b/pom.xml
@@ -320,6 +320,13 @@
${aws-sdk-batch.version}
+
+
+ software.amazon.awssdk
+ bedrockagentruntime
+ ${aws-sdk-batch.version}
+
+
diff --git a/src/main/java/activesupport/aws/s3/SecretsManager.java b/src/main/java/activesupport/aws/s3/SecretsManager.java
index cb389d1..18f6dc6 100644
--- a/src/main/java/activesupport/aws/s3/SecretsManager.java
+++ b/src/main/java/activesupport/aws/s3/SecretsManager.java
@@ -69,14 +69,19 @@ private static AWSSecretsManager awsClientSetup() {
}
public static String getSecretValue(String secretKey) {
- if (cache.containsKey(secretKey)) {
- return cache.get(secretKey);
+ return getSecretValue(secretsId, secretKey);
+ }
+
+ public static String getSecretValue(String secretName, String secretKey) {
+ String cacheKey = secretName + ":" + secretKey;
+ if (cache.containsKey(cacheKey)) {
+ return cache.get(cacheKey);
}
String secret = null;
GetSecretValueRequest getSecretValueRequest = new GetSecretValueRequest()
- .withSecretId(secretsId);
+ .withSecretId(secretName);
GetSecretValueResult getSecretValueResult = null;
try {
@@ -92,8 +97,10 @@ public static String getSecretValue(String secretKey) {
if (getSecretValueResult != null && getSecretValueResult.getSecretString() != null) {
secret = getSecretValueResult.getSecretString();
JsonObject jsonObject = JsonParser.parseString(secret).getAsJsonObject();
- secret = jsonObject.get(secretKey).getAsString();
- cache.put(secretKey, secret);
+ if (jsonObject.has(secretKey)) {
+ secret = jsonObject.get(secretKey).getAsString();
+ cache.put(cacheKey, secret);
+ }
}
return secret;
}
diff --git a/src/main/java/activesupport/selfhealing/BedrockSelectorService.java b/src/main/java/activesupport/selfhealing/BedrockSelectorService.java
new file mode 100644
index 0000000..ffc03b4
--- /dev/null
+++ b/src/main/java/activesupport/selfhealing/BedrockSelectorService.java
@@ -0,0 +1,140 @@
+package activesupport.selfhealing;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.bedrockagentruntime.BedrockAgentRuntimeAsyncClient;
+import software.amazon.awssdk.services.bedrockagentruntime.model.InvokeAgentRequest;
+import software.amazon.awssdk.services.bedrockagentruntime.model.InvokeAgentResponseHandler;
+
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+
+public class BedrockSelectorService {
+
+ private static final Logger LOGGER = LogManager.getLogger(BedrockSelectorService.class);
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private BedrockAgentRuntimeAsyncClient client;
+
+ private BedrockAgentRuntimeAsyncClient getClient() {
+ if (client == null) {
+ client = BedrockAgentRuntimeAsyncClient.builder()
+ .region(Region.of(SelfHealingConfig.getRegion()))
+ .build();
+ }
+ return client;
+ }
+
+ public Optional findCorrectedSelector(String brokenSelector, String selectorType, String pageSource) {
+ try {
+ String truncatedDom = truncateDom(pageSource, SelfHealingConfig.getMaxDomLength());
+ String prompt = buildPrompt(brokenSelector, selectorType, truncatedDom);
+
+ LOGGER.info("SELF-HEALING: Querying Bedrock agent for broken {} selector: {}", selectorType, brokenSelector);
+
+ String sessionId = UUID.randomUUID().toString();
+ StringBuilder responseText = new StringBuilder();
+
+ InvokeAgentResponseHandler handler = InvokeAgentResponseHandler.builder()
+ .onResponse(response -> LOGGER.debug("SELF-HEALING: Agent response received"))
+ .subscriber(InvokeAgentResponseHandler.Visitor.builder()
+ .onChunk(chunk -> {
+ if (chunk.bytes() != null) {
+ responseText.append(chunk.bytes().asUtf8String());
+ }
+ })
+ .build())
+ .onError(error -> LOGGER.error("SELF-HEALING: Agent streaming error", error))
+ .build();
+
+ CompletableFuture future = getClient().invokeAgent(
+ InvokeAgentRequest.builder()
+ .agentId(SelfHealingConfig.getAgentId())
+ .agentAliasId(SelfHealingConfig.getAgentAliasId())
+ .sessionId(sessionId)
+ .inputText(prompt)
+ .build(),
+ handler
+ );
+
+ future.get(30, TimeUnit.SECONDS);
+
+ return parseResponse(responseText.toString());
+
+ } catch (Exception e) {
+ LOGGER.warn("SELF-HEALING: Failed to query Bedrock agent: {}", e.getMessage());
+ return Optional.empty();
+ }
+ }
+
+ private String buildPrompt(String brokenSelector, String selectorType, String dom) {
+ return String.format(
+ "You are a Selenium selector repair assistant. A %s selector is broken and needs fixing.\n\n"
+ + "BROKEN SELECTOR: %s\n\n"
+ + "RULES:\n"
+ + "1. Analyse the HTML below and find the correct selector for the same target element.\n"
+ + "2. PRESERVE positional indices like [2], [last()], [position()>1] etc. — they exist because "
+ + "multiple elements match and the test targets a specific one (e.g. a visible checkbox vs a hidden input).\n"
+ + "3. The corrected selector MUST target a VISIBLE, INTERACTABLE element — never a type=\"hidden\" input "
+ + "when the original clearly intended a clickable/visible control.\n"
+ + "4. Only fix what is broken (e.g. a typo in an attribute value). Do not restructure or simplify the selector.\n"
+ + "5. Respond ONLY with JSON: {\"selector\": \"...\", \"confidence\": 0.0-1.0, \"reason\": \"...\"}\n\n"
+ + "PAGE HTML:\n%s",
+ selectorType, brokenSelector, dom
+ );
+ }
+
+ private Optional parseResponse(String response) {
+ try {
+ String jsonStr = extractJson(response);
+ JsonNode json = MAPPER.readTree(jsonStr);
+
+ String selector = json.get("selector").asText();
+ double confidence = json.get("confidence").asDouble();
+ String reason = json.has("reason") ? json.get("reason").asText() : "unknown";
+
+ if (selector == null || selector.isBlank()) {
+ LOGGER.warn("SELF-HEALING: Agent returned empty selector");
+ return Optional.empty();
+ }
+
+ LOGGER.info("SELF-HEALING: Agent suggested selector: {} (confidence: {}, reason: {})",
+ selector, confidence, reason);
+
+ return Optional.of(new HealResult(selector, confidence, reason));
+
+ } catch (Exception e) {
+ LOGGER.warn("SELF-HEALING: Failed to parse agent response: {} | Raw response: {}",
+ e.getMessage(), response);
+ return Optional.empty();
+ }
+ }
+
+ private String extractJson(String response) {
+ int start = response.indexOf('{');
+ int end = response.lastIndexOf('}');
+ if (start >= 0 && end > start) {
+ return response.substring(start, end + 1);
+ }
+ return response;
+ }
+
+ static String truncateDom(String pageSource, int maxLength) {
+ if (pageSource == null) return "";
+ // Strip script and style content to maximise useful DOM in the context window
+ String cleaned = pageSource
+ .replaceAll("(?s)", "")
+ .replaceAll("(?s)", "")
+ .replaceAll("\\s{2,}", " ");
+
+ if (cleaned.length() <= maxLength) {
+ return cleaned;
+ }
+ return cleaned.substring(0, maxLength) + "\n";
+ }
+}
diff --git a/src/main/java/activesupport/selfhealing/HealResult.java b/src/main/java/activesupport/selfhealing/HealResult.java
new file mode 100644
index 0000000..9fbec7a
--- /dev/null
+++ b/src/main/java/activesupport/selfhealing/HealResult.java
@@ -0,0 +1,31 @@
+package activesupport.selfhealing;
+
+public class HealResult {
+
+ private final String selector;
+ private final double confidence;
+ private final String reason;
+
+ public HealResult(String selector, double confidence, String reason) {
+ this.selector = selector;
+ this.confidence = confidence;
+ this.reason = reason;
+ }
+
+ public String getSelector() {
+ return selector;
+ }
+
+ public double getConfidence() {
+ return confidence;
+ }
+
+ public String getReason() {
+ return reason;
+ }
+
+ @Override
+ public String toString() {
+ return String.format("HealResult{selector='%s', confidence=%.2f, reason='%s'}", selector, confidence, reason);
+ }
+}
diff --git a/src/main/java/activesupport/selfhealing/SelfHealingConfig.java b/src/main/java/activesupport/selfhealing/SelfHealingConfig.java
new file mode 100644
index 0000000..c1600ab
--- /dev/null
+++ b/src/main/java/activesupport/selfhealing/SelfHealingConfig.java
@@ -0,0 +1,56 @@
+package activesupport.selfhealing;
+
+import activesupport.aws.s3.SecretsManager;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+public final class SelfHealingConfig {
+
+ private static final Logger LOGGER = LogManager.getLogger(SelfHealingConfig.class);
+ private static final String PREFIX = "selfHealing.";
+ private static final String DEFAULT_SECRET_NAME = "vol-functional-tests/bedrock";
+
+ private SelfHealingConfig() {
+ }
+
+ public static boolean isEnabled() {
+ return Boolean.parseBoolean(System.getProperty(PREFIX + "enabled", "false"));
+ }
+
+ public static String getRegion() {
+ return System.getProperty(PREFIX + "region", "eu-west-1");
+ }
+
+ public static String getAgentId() {
+ return getConfigValue("selfHeal_agentId");
+ }
+
+ public static String getAgentAliasId() {
+ return getConfigValue("selfHealth_agentAliasId");
+ }
+
+ public static int getMaxDomLength() {
+ return Integer.parseInt(System.getProperty(PREFIX + "maxDomLength", "50000"));
+ }
+
+ public static double getMinConfidence() {
+ return Double.parseDouble(System.getProperty(PREFIX + "minConfidence", "0.5"));
+ }
+
+ /**
+ * Resolves a config value: system property first, then AWS Secrets Manager.
+ */
+ private static String getConfigValue(String key) {
+ String prop = System.getProperty(PREFIX + key);
+ if (prop != null && !prop.isBlank()) {
+ return prop;
+ }
+ try {
+ String secretName = System.getProperty(PREFIX + "secretName", DEFAULT_SECRET_NAME);
+ return SecretsManager.getSecretValue(secretName, key);
+ } catch (Exception e) {
+ LOGGER.warn("SELF-HEALING: Failed to fetch '{}' from Secrets Manager: {}", key, e.getMessage());
+ return "";
+ }
+ }
+}
diff --git a/src/main/java/activesupport/selfhealing/SelfHealingElementLocator.java b/src/main/java/activesupport/selfhealing/SelfHealingElementLocator.java
new file mode 100644
index 0000000..dda5721
--- /dev/null
+++ b/src/main/java/activesupport/selfhealing/SelfHealingElementLocator.java
@@ -0,0 +1,125 @@
+package activesupport.selfhealing;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.openqa.selenium.By;
+import org.openqa.selenium.WebDriver;
+import org.openqa.selenium.WebElement;
+
+import java.util.Optional;
+
+public class SelfHealingElementLocator {
+
+ private static final Logger LOGGER = LogManager.getLogger(SelfHealingElementLocator.class);
+ private static final BedrockSelectorService bedrockService = new BedrockSelectorService();
+
+ private SelfHealingElementLocator() {
+ }
+
+ /**
+ * Attempts to heal a broken selector by analysing the current page DOM with AI.
+ * Returns the healed WebElement if successful, or empty if healing is disabled,
+ * fails, or the confidence is too low.
+ *
+ * @param driver the active WebDriver instance
+ * @param brokenSelector the selector string that failed
+ * @param selectorType the type of selector (CSS, XPATH, ID, etc.)
+ * @return Optional containing the found WebElement if healing succeeded
+ */
+ public static Optional heal(WebDriver driver, String brokenSelector, String selectorType) {
+ if (!SelfHealingConfig.isEnabled()) {
+ return Optional.empty();
+ }
+
+ try {
+ LOGGER.warn("SELF-HEALING: Attempting to heal broken {} selector: {}", selectorType, brokenSelector);
+
+ String pageSource = driver.getPageSource();
+ Optional result = bedrockService.findCorrectedSelector(brokenSelector, selectorType, pageSource);
+
+ if (result.isEmpty()) {
+ LOGGER.warn("SELF-HEALING: Agent could not find a replacement selector");
+ return Optional.empty();
+ }
+
+ HealResult heal = result.get();
+
+ if (heal.getConfidence() < SelfHealingConfig.getMinConfidence()) {
+ LOGGER.warn("SELF-HEALING: Confidence too low ({}) for selector: {}",
+ heal.getConfidence(), heal.getSelector());
+ return Optional.empty();
+ }
+
+ By healedBy = toBy(heal.getSelector(), selectorType);
+ WebElement element = driver.findElement(healedBy);
+
+ // If the healed element is a hidden input, look for a visible sibling with the same name
+ if (!element.isDisplayed()) {
+ LOGGER.warn("SELF-HEALING: Healed element is not displayed, searching for visible alternative");
+ java.util.List candidates = driver.findElements(healedBy);
+ Optional visible = candidates.stream()
+ .filter(WebElement::isDisplayed)
+ .findFirst();
+ if (visible.isPresent()) {
+ element = visible.get();
+ LOGGER.info("SELF-HEALING: Found visible alternative (element {} of {})",
+ candidates.indexOf(element) + 1, candidates.size());
+ } else {
+ LOGGER.warn("SELF-HEALING: No visible alternative found — using original healed element");
+ }
+ }
+
+ LOGGER.warn(
+ "\n╔══════════════════════════════════════════════════════════════╗\n" +
+ "║ SELF-HEALED SELECTOR ║\n" +
+ "╠══════════════════════════════════════════════════════════════╣\n" +
+ "║ Type: {}\n" +
+ "║ Broken: {}\n" +
+ "║ Healed: {}\n" +
+ "║ Confidence: {}\n" +
+ "║ Reason: {}\n" +
+ "║ Page: {}\n" +
+ "╠══════════════════════════════════════════════════════════════╣\n" +
+ "║ ⚠ UPDATE YOUR TEST CODE WITH THE HEALED SELECTOR ║\n" +
+ "╚══════════════════════════════════════════════════════════════╝",
+ selectorType, brokenSelector, heal.getSelector(),
+ heal.getConfidence(), heal.getReason(), driver.getCurrentUrl()
+ );
+
+ return Optional.of(element);
+
+ } catch (Exception e) {
+ LOGGER.warn("SELF-HEALING: Healing attempt failed: {}", e.getMessage());
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * Converts the healed selector string to a Selenium By locator.
+ * Auto-detects the format returned by the AI agent rather than
+ * blindly trusting the original selectorType — the agent may return
+ * a CSS selector (#id) even when the broken selector was type ID.
+ */
+ static By toBy(String selector, String selectorType) {
+ // Agent returned an XPath expression
+ if (selector.startsWith("//") || selector.startsWith("(//")) {
+ return By.xpath(selector);
+ }
+
+ // Agent returned a CSS #id selector — use cssSelector, not By.id
+ if (selector.startsWith("#") || selector.startsWith(".") || selector.contains("[")) {
+ return By.cssSelector(selector);
+ }
+
+ // No prefix detected — fall back to the original type
+ return switch (selectorType.toUpperCase()) {
+ case "CSS" -> By.cssSelector(selector);
+ case "XPATH" -> By.xpath(selector);
+ case "ID" -> By.id(selector);
+ case "NAME" -> By.name(selector);
+ case "LINKTEXT" -> By.linkText(selector);
+ case "PARTIALLINKTEXT" -> By.partialLinkText(selector);
+ default -> By.cssSelector(selector);
+ };
+ }
+}
diff --git a/src/test/java/activesupport/selfhealing/BedrockSelectorServiceTest.java b/src/test/java/activesupport/selfhealing/BedrockSelectorServiceTest.java
new file mode 100644
index 0000000..9df0ee0
--- /dev/null
+++ b/src/test/java/activesupport/selfhealing/BedrockSelectorServiceTest.java
@@ -0,0 +1,51 @@
+package activesupport.selfhealing;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class BedrockSelectorServiceTest {
+
+ @Test
+ void truncateDomStripsScriptTags() {
+ String html = "Hello
";
+ String result = BedrockSelectorService.truncateDom(html, 50000);
+ assertFalse(result.contains("