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("